build(agent-runtime): harness runtime images for sandboxed execution (stage 3/3) (#7934)

> [!NOTE]
> This is **stage 3 of 3** of the staged Kubernetes contribution: stage
1 is the kubernetes sandbox-provider plugin (#5790), stage 2 is the
provider backend/hardening refresh filed separately, and this stage
ships the runtime images those sandboxes run.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandboxed agent execution (Refs #248) runs each agent turn in an
isolated environment; the kubernetes sandbox provider (stage 1, #5790)
schedules those runs as hardened pods
> - A sandbox pod needs a runtime image with the harness CLI
preinstalled: installing CLIs at run start is slow, flaky, and needs
network egress the sandbox should not have
> - There is no first-party image family for this, so every deployer
would have to hand-roll Ubuntu + Node + CLI images per harness and solve
signal handling, non-root, and image chaining themselves
> - This PR ships the agent-runtime image family: a hardened base
(non-root uid 1000, tini, git, the agent shim) plus one derived image
per harness, a buildx bake file that chains them, and a publish workflow
with cosign keyless signing
> - The benefit is that any sandbox infrastructure, the kubernetes
provider or otherwise, gets ready-made, signed, security-hardened
per-harness runtime images that are verified in production across five
harnesses

## Linked Issues or Issue Description

Refs #248 (sandboxed agent execution proposal) and #5790 (the kubernetes
sandbox provider, stage 1 of this contribution, which consumes these
images as per-run runtime images via its adapter defaults).

No issue covers the image gap itself, described in-PR: sandbox providers
reference `ghcr.io/paperclipai/agent-runtime-*` images, but the
repository contains neither the Dockerfiles nor the workflow that builds
and publishes them. Without this, self-deployers cannot reproduce or
audit the images their agent runs execute in.

## What Changed

- `docker/agent-runtime/Dockerfile.base`: foundation image. Ubuntu 22.04
+ Node 22 + git + tini (PID 1, signal propagation) + non-root
`paperclip` user (uid/gid 1000) + the agent shim compiled in a Go build
stage. `WORKDIR /workspace`, entrypoint `tini -- paperclip-agent-shim`.
- One derived Dockerfile per harness: `opencode` (opencode-ai), `pi`
(@mariozechner/pi-coding-agent), `codex` (@openai/codex), `gemini`
(@google/gemini-cli, plus headless auth-mode settings), `claude`
(@anthropic-ai/claude-code, symlinked as `claude-code`). Each installs
the CLI as root, returns to uid 1000, and asserts the binary is on PATH
at build time.
- `acpx` and `hermes` Dockerfiles are included in the bake group but are
not in the default publish scope (hermes is a stub until a CLI package
exists).
- `docker/agent-runtime/buildx-bake.hcl`: builds the whole family in one
pass. Derived targets chain off the `base` target through bake
`contexts` (the literal registry in each `FROM` is overridden to
`target:base` at build time, so no intermediate push is needed).
`REGISTRY` (default `ghcr.io/paperclipai`) and `VERSION` are overridable
variables.
- `tools/agent-shim/`: a small Go shim that runs as the container
command. It reads `/run/paperclip/runtime-command.json` (`{ "command",
"args" }`), resolves the harness CLI on PATH, and `syscall.Exec`s it so
SIGTERM from the kubelet reaches the harness directly. Harness-agnostic,
with unit tests.
- `.github/workflows/agent-runtime-images.yml`: builds and pushes the
default scope (base, opencode, pi, codex, gemini, claude) for
linux/amd64 on `workflow_dispatch` (explicit version tag) or pushes to
`master` touching these paths, then signs every digest with cosign
keyless OIDC. Uses only `GITHUB_TOKEN`; no extra secrets.
- `docker/agent-runtime/README.md`: image lineup, base contents, local
build instructions, the runtime-command contract, and the security
model.

Additive only: nothing in the product loads these images. Deployments
opt in via their sandbox provider configuration (for example the
kubernetes plugin's image settings).

## Verification

- `cd tools/agent-shim && go build ./... && go test ./... && go vet
./...`: all passing.
- `docker buildx bake -f docker/agent-runtime/buildx-bake.hcl --print
base opencode pi codex gemini claude`: resolves cleanly; every tag and
build context lands on `ghcr.io/paperclipai/agent-runtime-*` and derived
targets map the base ref to `target:base`.
- Workflow YAML validated (parses, single job, no org-specific secrets).
- This exact image family (built from these Dockerfiles, bake file, and
workflow) is what runs agent execution in production on paperclip.inc,
verified end-to-end across five harnesses (opencode, pi, codex, gemini,
claude): each as a full loop from assigned issue to per-run runtime
image in a sandboxed pod to completed run.

## Risks

- Low risk: purely additive, nothing in paperclip-server or the UI
references these files. The workflow only triggers on its own paths.
- Derived images install harness CLIs `@latest` at build time; a broken
upstream CLI release would surface at image build, not at run time, and
the PATH assertion fails the build rather than shipping a broken image.
- The hermes image is an explicit stub (documented in its Dockerfile)
until a hermes CLI package exists; it is outside the default publish
scope.
- cosign signing is keyless OIDC with the workflow identity; no
long-lived signing keys are introduced.

## Model Used

Claude Opus 4.8 (claude-opus-4-8, 1M context, extended thinking, tool
use via Claude Code).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jannes Stubbemann
2026-06-11 06:10:23 +02:00
committed by GitHub
parent 4ad94d0bde
commit 398d746093
16 changed files with 536 additions and 0 deletions
@@ -0,0 +1,98 @@
name: Agent runtime images
on:
push:
branches: [master]
paths:
- "docker/agent-runtime/**"
- "tools/agent-shim/**"
- ".github/workflows/agent-runtime-images.yml"
workflow_dispatch:
inputs:
version:
description: "Image version tag (e.g., v1.0.0 or dev-test)"
required: true
default: "dev"
permissions:
contents: read
packages: write
id-token: write # cosign keyless OIDC
env:
REGISTRY: ghcr.io/paperclipai
VERSION: ${{ github.event.inputs.version || format('git-{0}', github.sha) }}
jobs:
build-and-sign:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Build + push all deployed harness images (linux/amd64)
id: bake
run: |
# Scope: the five harnesses the kubernetes sandbox provider supports
# out of the box (opencode, pi, codex, gemini, claude). acpx/hermes
# have Dockerfiles in the bake group but are not published by default.
# Platforms come from the bake HCL (linux/amd64).
# Use --push rather than a "*.push" --set so the wildcard does not hit
# the bake group (which has no such key -> "unknown key" error).
docker buildx bake \
-f docker/agent-runtime/buildx-bake.hcl \
base opencode pi codex gemini claude \
--push \
--metadata-file=bake-metadata.json
# Extract digests for cosign signing
BASE_DIGEST=$(jq -r '."base"."containerimage.digest"' bake-metadata.json)
OPENCODE_DIGEST=$(jq -r '."opencode"."containerimage.digest"' bake-metadata.json)
PI_DIGEST=$(jq -r '."pi"."containerimage.digest"' bake-metadata.json)
CODEX_DIGEST=$(jq -r '."codex"."containerimage.digest"' bake-metadata.json)
GEMINI_DIGEST=$(jq -r '."gemini"."containerimage.digest"' bake-metadata.json)
CLAUDE_DIGEST=$(jq -r '."claude"."containerimage.digest"' bake-metadata.json)
echo "base_digest=$BASE_DIGEST" >> "$GITHUB_OUTPUT"
echo "opencode_digest=$OPENCODE_DIGEST" >> "$GITHUB_OUTPUT"
echo "pi_digest=$PI_DIGEST" >> "$GITHUB_OUTPUT"
echo "codex_digest=$CODEX_DIGEST" >> "$GITHUB_OUTPUT"
echo "gemini_digest=$GEMINI_DIGEST" >> "$GITHUB_OUTPUT"
echo "claude_digest=$CLAUDE_DIGEST" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ env.VERSION }}
REGISTRY: ${{ env.REGISTRY }}
- name: Cosign sign base
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-base@${{ steps.bake.outputs.base_digest }}"
- name: Cosign sign opencode
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-opencode@${{ steps.bake.outputs.opencode_digest }}"
- name: Cosign sign pi
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-pi@${{ steps.bake.outputs.pi_digest }}"
- name: Cosign sign codex
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-codex@${{ steps.bake.outputs.codex_digest }}"
- name: Cosign sign gemini
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-gemini@${{ steps.bake.outputs.gemini_digest }}"
- name: Cosign sign claude
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-claude@${{ steps.bake.outputs.claude_digest }}"
+14
View File
@@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
# acpx is the ACPX wrapper that bridges to claude / codex backends.
# Verified npm package name: "acpx" (bin 'acpx' → dist/cli.js).
RUN npm install -g acpx@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v acpx >/dev/null 2>&1 || (echo "acpx not on PATH"; exit 1)
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.6
ARG NODE_VERSION=22
ARG TARGETARCH
# ---------- Stage 1: build agent-shim ----------
FROM golang:1.25-bookworm AS shim-build
ARG TARGETARCH
WORKDIR /src
COPY tools/agent-shim/ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \
go build -ldflags='-s -w' -o /out/paperclip-agent-shim .
# ---------- Stage 2: runtime base image ----------
# Ubuntu + Node + git + tini + non-root paperclip user + the agent-shim. This is
# everything the sandbox exec-in model needs: the pod idles and paperclip-server
# execs the harness CLI in, so the harness images only add their CLI on top of
# this. Git workspace population (an init-container workspace bootstrap) is
# intentionally NOT built here yet: the current scope is a blank workspace.
# Add it when git workspace population lands.
FROM ubuntu:22.04 AS base
ARG NODE_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git tini gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -g 1000 paperclip \
&& useradd -u 1000 -g 1000 -d /home/paperclip -m -s /bin/bash paperclip
COPY --from=shim-build /out/paperclip-agent-shim /usr/local/bin/paperclip-agent-shim
USER 1000:1000
WORKDIR /workspace
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/paperclip-agent-shim"]
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
RUN npm install -g @anthropic-ai/claude-code@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
# @anthropic-ai/claude-code installs as 'claude', but the shim expects 'claude-code'.
# Resolve the real install path and fail the build if claude is missing.
RUN CLAUDE_BIN="$(command -v claude)" \
&& ln -s "$CLAUDE_BIN" /usr/bin/claude-code
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v claude-code >/dev/null 2>&1 || (echo "claude-code not on PATH"; exit 1)
+12
View File
@@ -0,0 +1,12 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
RUN npm install -g @openai/codex@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v codex >/dev/null 2>&1 || (echo "codex not on PATH"; exit 1)
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
RUN npm install -g @google/gemini-cli@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v gemini >/dev/null 2>&1 || (echo "gemini not on PATH"; exit 1)
# Pre-select the gemini-api-key auth mode: gemini-cli refuses headless runs with
# "Invalid auth method selected." unless settings.json picks an auth type (the
# GEMINI_DEFAULT_AUTH_TYPE env alone does NOT work, proven empirically). Both
# schema generations are written (selectedAuthType = legacy, security.auth
# .selectedType = current). Correct for managed-gateway AND BYOK api-key modes;
# OAuth flows are interactive and out of scope for sandbox pods.
RUN mkdir -p /home/paperclip/.gemini \
&& printf '%s\n' '{"selectedAuthType":"gemini-api-key","security":{"auth":{"selectedType":"gemini-api-key"}}}' > /home/paperclip/.gemini/settings.json
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
# hermes_local is in the legacy sessioned-adapter set but has no upstream
# npm package wired into Paperclip locally yet (see
# packages/adapter-utils/src/session-compaction.ts vs the absent
# packages/adapters/hermes-local/). This stub image preserves a cloud
# runtime slot for hermes_local; whoever ports the hermes binary to
# Paperclip locally adds the `npm install -g <pkg>` and the PATH check
# to this Dockerfile.
#
# Until then, runs targeting hermes_local on the cloud adapter will boot
# the container but `agent-shim` will fail with "hermes not on PATH" when
# it tries to invoke the CLI.
USER 1000:1000
+13
View File
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
# opencode-ai npm package; binary on PATH is `opencode`.
RUN npm install -g opencode-ai@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v opencode >/dev/null 2>&1 || (echo "opencode not on PATH"; exit 1)
+15
View File
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1.6
ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
# pi is the multi-provider Pi coding-agent router. Verified npm package:
# @mariozechner/pi-coding-agent (binary 'pi' → dist/cli.js). Same package
# is used by SANDBOX_INSTALL_COMMAND in pi-local/src/index.ts.
RUN npm install -g @mariozechner/pi-coding-agent@latest \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
# Verify the CLI is on PATH for the shim's exec.LookPath
RUN command -v pi >/dev/null 2>&1 || (echo "pi not on PATH"; exit 1)
+85
View File
@@ -0,0 +1,85 @@
# Agent Runtime Image Family
Container images for running coding-agent harnesses in sandboxed environments (for example the kubernetes sandbox provider, stage 1 of the k8s contribution). Images are named `agent-runtime-{harness}:{version}` and published to `ghcr.io/paperclipai/` by the `agent-runtime-images` workflow. The registry is overridable: every reference flows through the `REGISTRY` bake variable.
## Image Lineup
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 22 + git + tini + non-root user (uid 1000) + the agent shim.
- **`agent-runtime-opencode`**: Extends base with `opencode-ai` globally installed.
- **`agent-runtime-pi`**: Extends base with `@mariozechner/pi-coding-agent`.
- **`agent-runtime-codex`**: Extends base with `@openai/codex`.
- **`agent-runtime-gemini`**: Extends base with `@google/gemini-cli` plus headless auth-mode settings.
- **`agent-runtime-claude`**: Extends base with `@anthropic-ai/claude-code` (symlinked as `claude-code`).
- **`agent-runtime-acpx`** / **`agent-runtime-hermes`**: Dockerfiles included in the bake group, not in the default publish scope (hermes is a stub until a CLI package exists).
## Base Image Contents
**OS & Runtime:**
- Ubuntu 22.04
- Node.js 22 (via NodeSource APT repo)
- git
- tini (PID-1 init, ensures signal propagation)
- Non-root user `paperclip` (uid/gid 1000)
**Paperclip Binaries:**
- `/usr/local/bin/paperclip-agent-shim`: Go binary compiled from `tools/agent-shim/`. Reads `/run/paperclip/runtime-command.json` and `syscall.Exec`s the harness CLI.
**Defaults:**
- `USER`: 1000:1000 (paperclip, non-root)
- `WORKDIR`: `/workspace` (mount workspace volumes here)
- `ENTRYPOINT`: `/usr/bin/tini --` (PID-1 reaper, forwards signals)
- `CMD`: `/usr/local/bin/paperclip-agent-shim`
## Building Locally
All targets build `linux/amd64` by default (see `buildx-bake.hcl`). Derived images chain off the `base` target through bake `contexts`, so the literal registry in each `FROM` line is overridden at build time and the whole family builds in one pass without pushing intermediates.
```bash
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl --load
```
### Custom tag or registry
```bash
REGISTRY=myregistry VERSION=mytag \
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl --load
```
## Quickstart Smoke Test
Build and verify the `agent-runtime-claude` image runs locally:
```bash
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl base claude --load
docker run --rm ghcr.io/paperclipai/agent-runtime-claude:dev claude-code --version
```
## Agent Container (paperclip-agent-shim)
The main agent process runs as the shim (PID 1 under tini). The shim:
1. Reads `/run/paperclip/runtime-command.json` (path overridable via `-spec`), a JSON file mounted by whatever schedules the run
2. Parses `{ "command", "args" }`: the harness CLI and arguments
3. Resolves the command on PATH and `syscall.Exec`s it, replacing itself
4. SIGTERM from the kubelet propagates directly to the harness (no zombie processes)
**runtime-command.json Contract:**
```json
{
"command": "claude-code",
"args": ["--token", "xyz", "--workspace", "/workspace"]
}
```
The shim makes no assumptions about command structure; it is harness-agnostic. New harnesses swap the command/args; the base image stays the same.
## Security Model
- **Non-root execution**: user 1000:1000, no capability grants
- **PSS Restricted compatible**: no privileged containers, no host mounts; works with a read-only root filesystem (writable `/workspace` + `/tmp` mounts)
- **No secrets baked in**: API tokens and credentials come from per-run ephemeral Secrets mounted as env vars or files
- **Image signing**: cosign keyless OIDC in the publish workflow
## Publishing
`.github/workflows/agent-runtime-images.yml` builds and pushes the default scope (base, opencode, pi, codex, gemini, claude) on `workflow_dispatch` (with an explicit version tag) or on pushes to `master` touching these paths, then signs each digest with cosign keyless OIDC.
+104
View File
@@ -0,0 +1,104 @@
group "default" {
targets = ["base", "claude", "codex", "gemini", "acpx", "opencode", "pi", "hermes"]
}
variable "VERSION" { default = "dev" }
variable "REGISTRY" { default = "ghcr.io/paperclipai" }
target "base" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.base"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-base:${VERSION}"]
}
target "claude" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.claude"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-claude:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "codex" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.codex"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-codex:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "gemini" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.gemini"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-gemini:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "acpx" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.acpx"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-acpx:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "opencode" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.opencode"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-opencode:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "pi" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.pi"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-pi:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
target "hermes" {
context = "."
dockerfile = "docker/agent-runtime/Dockerfile.hermes"
platforms = ["linux/amd64"]
tags = ["${REGISTRY}/agent-runtime-hermes:${VERSION}"]
args = {
BASE_TAG = "${VERSION}"
}
contexts = {
"paperclipai/agent-runtime-base:${VERSION}" = "target:base"
}
}
+2
View File
@@ -0,0 +1,2 @@
/bin/
/agent-shim
+3
View File
@@ -0,0 +1,3 @@
module github.com/paperclipai/paperclip/tools/agent-shim
go 1.22
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"syscall"
)
const (
defaultRuntimeCommandPath = "/run/paperclip/runtime-command.json"
)
func main() {
specPath := flag.String("spec", defaultRuntimeCommandPath, "path to AdapterRuntimeCommandSpec JSON")
adapterType := flag.String("adapter", "", "adapter type (informational; e.g. claude_local)")
flag.Parse()
spec, err := loadRuntimeCommandSpec(*specPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[shim] cannot read runtime command spec: %v\n", err)
os.Exit(2)
}
resolved, err := exec.LookPath(spec.Command)
if err != nil {
fmt.Fprintf(os.Stderr, "[shim] adapter command %q not found in PATH (adapter=%s)\n", spec.Command, *adapterType)
os.Exit(127)
}
// Build argv (resolved binary as argv[0])
argv := append([]string{resolved}, spec.Args...)
// syscall.Exec replaces this process; SIGTERM from k8s reaches the adapter directly.
if err := syscall.Exec(resolved, argv, os.Environ()); err != nil {
fmt.Fprintf(os.Stderr, "[shim] exec %q failed: %v\n", resolved, err)
os.Exit(126)
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRuntimeCommandSpec_OK(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "spec.json")
_ = os.WriteFile(p, []byte(`{"command":"claude-code","args":["--print"]}`), 0o600)
spec, err := loadRuntimeCommandSpec(p)
if err != nil {
t.Fatalf("expected ok, got %v", err)
}
if spec.Command != "claude-code" || len(spec.Args) != 1 {
t.Fatalf("unexpected spec: %+v", spec)
}
}
func TestLoadRuntimeCommandSpec_MissingCommand(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "spec.json")
_ = os.WriteFile(p, []byte(`{"command":""}`), 0o600)
if _, err := loadRuntimeCommandSpec(p); err == nil {
t.Fatal("expected error for empty command")
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"encoding/json"
"errors"
"os"
)
type RuntimeCommandSpec struct {
Command string `json:"command"`
Args []string `json:"args"`
DetectCommand string `json:"detectCommand,omitempty"`
InstallCommand string `json:"installCommand,omitempty"`
}
func loadRuntimeCommandSpec(path string) (*RuntimeCommandSpec, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var spec RuntimeCommandSpec
if err := json.Unmarshal(b, &spec); err != nil {
return nil, err
}
if spec.Command == "" {
return nil, errors.New("runtime-command.json has empty 'command'")
}
return &spec, nil
}