Skip to content

Commit 8ccc47a

Browse files
stubbiclaude
andauthored
build: agent-runtime images (base/opencode/pi) -> paperclipinc (#105)
* build(agent-runtime): resurrect agent-runtime image build, publish to paperclipinc Brings docker/agent-runtime (Dockerfile.base + per-harness Dockerfiles + buildx-bake.hcl), tools/agent-shim, tools/workspace-init, and .github/workflows/agent-runtime-images.yml from feat/k8s-cloud-adapter-m4a-bugfixes. Repoints all REGISTRY references from ghcr.io/paperclipai to ghcr.io/paperclipinc. Extends cosign signing to cover opencode and pi in addition to base and claude. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build(agent-runtime): scope publish to base+opencode+pi, trigger on main --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7f72082 commit 8ccc47a

25 files changed

Lines changed: 913 additions & 0 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Agent runtime images
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "docker/agent-runtime/**"
8+
- "tools/agent-shim/**"
9+
- "tools/workspace-init/**"
10+
- "packages/workspace-strategy/**"
11+
- ".github/workflows/agent-runtime-images.yml"
12+
workflow_dispatch:
13+
inputs:
14+
version:
15+
description: "Image version tag (e.g., v1.0.0 or dev-test)"
16+
required: true
17+
default: "dev"
18+
19+
permissions:
20+
contents: read
21+
packages: write
22+
id-token: write # cosign keyless OIDC
23+
24+
env:
25+
REGISTRY: ghcr.io/paperclipinc
26+
VERSION: ${{ github.event.inputs.version || format('git-{0}', github.sha) }}
27+
28+
jobs:
29+
build-and-sign:
30+
runs-on: ubuntu-22.04
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Set up QEMU (multi-arch builds)
35+
uses: docker/setup-qemu-action@v3
36+
37+
- name: Set up Docker Buildx
38+
uses: docker/setup-buildx-action@v3
39+
40+
- name: Log into GHCR
41+
uses: docker/login-action@v3
42+
with:
43+
registry: ghcr.io
44+
username: ${{ github.actor }}
45+
password: ${{ secrets.GITHUB_TOKEN }}
46+
47+
- name: Install cosign
48+
uses: sigstore/cosign-installer@v3
49+
50+
- name: Build + push base + opencode + pi (multi-arch)
51+
id: bake
52+
run: |
53+
# Scope: cloud agent execution currently ships opencode + pi only. We
54+
# build just those two harness images (+ their base) to keep the
55+
# publish fast and avoid failing on harnesses we do not deploy yet.
56+
docker buildx bake \
57+
-f docker/agent-runtime/buildx-bake.hcl \
58+
base opencode pi \
59+
--set "*.platforms=linux/amd64,linux/arm64" \
60+
--set "*.push=true" \
61+
--metadata-file=bake-metadata.json
62+
# Extract digests for cosign signing
63+
BASE_DIGEST=$(jq -r '."base"."containerimage.digest"' bake-metadata.json)
64+
OPENCODE_DIGEST=$(jq -r '."opencode"."containerimage.digest"' bake-metadata.json)
65+
PI_DIGEST=$(jq -r '."pi"."containerimage.digest"' bake-metadata.json)
66+
echo "base_digest=$BASE_DIGEST" >> "$GITHUB_OUTPUT"
67+
echo "opencode_digest=$OPENCODE_DIGEST" >> "$GITHUB_OUTPUT"
68+
echo "pi_digest=$PI_DIGEST" >> "$GITHUB_OUTPUT"
69+
env:
70+
VERSION: ${{ env.VERSION }}
71+
REGISTRY: ${{ env.REGISTRY }}
72+
73+
- name: Cosign sign base
74+
run: |
75+
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-base@${{ steps.bake.outputs.base_digest }}"
76+
77+
- name: Cosign sign opencode
78+
run: |
79+
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-opencode@${{ steps.bake.outputs.opencode_digest }}"
80+
81+
- name: Cosign sign pi
82+
run: |
83+
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-pi@${{ steps.bake.outputs.pi_digest }}"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
# acpx is the ACPX wrapper that bridges to claude / codex backends.
7+
# Verified npm package name: "acpx" (bin 'acpx' → dist/cli.js).
8+
RUN npm install -g acpx@latest \
9+
&& chown -R 1000:1000 /usr/lib/node_modules \
10+
|| true
11+
12+
USER 1000:1000
13+
14+
# Verify the CLI is on PATH for the shim's exec.LookPath
15+
RUN command -v acpx >/dev/null 2>&1 || (echo "acpx not on PATH"; exit 1)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG NODE_VERSION=22
3+
ARG TARGETARCH
4+
5+
# ---------- Stage 1: build agent-shim ----------
6+
FROM golang:1.25-bookworm AS shim-build
7+
ARG TARGETARCH
8+
WORKDIR /src
9+
COPY tools/agent-shim/ ./
10+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \
11+
go build -ldflags='-s -w' -o /out/paperclip-agent-shim .
12+
13+
# ---------- Stage 2: build workspace-init (Node) ----------
14+
FROM node:${NODE_VERSION}-bookworm-slim AS wsinit-build
15+
WORKDIR /src
16+
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml tsconfig.base.json ./
17+
# Repo root pnpm config has patchedDependencies referencing ./patches/* — copy
18+
# the patch files so `pnpm install --frozen-lockfile` does not ENOENT on them
19+
# even though we install zero workspaces that consume embedded-postgres.
20+
COPY patches/ ./patches/
21+
COPY packages/workspace-strategy/ ./packages/workspace-strategy/
22+
COPY tools/workspace-init/ ./tools/workspace-init/
23+
RUN corepack enable && pnpm install --frozen-lockfile \
24+
&& pnpm --filter @paperclipai/workspace-strategy build \
25+
&& pnpm --filter @paperclipai/workspace-init build \
26+
# Materialize publishConfig into the live package.json so that Node's
27+
# production module resolution targets dist/ instead of src/*.ts. This
28+
# mirrors what `pnpm publish` does, but for our local consumption inside
29+
# the runtime image where TypeScript loaders are not present.
30+
&& node -e "const f='packages/workspace-strategy/package.json';const p=JSON.parse(require('fs').readFileSync(f,'utf8'));if(p.publishConfig&&p.publishConfig.exports){p.exports=p.publishConfig.exports;}require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\n');"
31+
32+
# ---------- Stage 3: runtime base image ----------
33+
FROM ubuntu:22.04 AS base
34+
ARG NODE_VERSION
35+
RUN apt-get update && apt-get install -y --no-install-recommends \
36+
ca-certificates curl git tini gnupg \
37+
&& rm -rf /var/lib/apt/lists/* \
38+
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
39+
&& apt-get install -y --no-install-recommends nodejs \
40+
&& apt-get clean \
41+
&& rm -rf /var/lib/apt/lists/* \
42+
&& groupadd -g 1000 paperclip \
43+
&& useradd -u 1000 -g 1000 -d /home/paperclip -m -s /bin/bash paperclip
44+
45+
COPY --from=shim-build /out/paperclip-agent-shim /usr/local/bin/paperclip-agent-shim
46+
47+
# workspace-init resolves @paperclipai/workspace-strategy via a pnpm workspace
48+
# symlink (tools/workspace-init/node_modules/@paperclipai/workspace-strategy ->
49+
# ../../../../packages/workspace-strategy). Mirror the /src layout under
50+
# /opt/paperclip/app so Node's module resolution and that relative symlink
51+
# both keep working at runtime.
52+
COPY --from=wsinit-build /src/tools/workspace-init/package.json /opt/paperclip/app/tools/workspace-init/package.json
53+
COPY --from=wsinit-build /src/tools/workspace-init/dist /opt/paperclip/app/tools/workspace-init/dist
54+
COPY --from=wsinit-build /src/tools/workspace-init/node_modules /opt/paperclip/app/tools/workspace-init/node_modules
55+
COPY --from=wsinit-build /src/packages/workspace-strategy/package.json /opt/paperclip/app/packages/workspace-strategy/package.json
56+
COPY --from=wsinit-build /src/packages/workspace-strategy/dist /opt/paperclip/app/packages/workspace-strategy/dist
57+
COPY --from=wsinit-build /src/node_modules /opt/paperclip/app/node_modules
58+
59+
# Convenience launcher so the init container can just run `paperclip-workspace-init`
60+
RUN printf '#!/bin/sh\nexec node --enable-source-maps /opt/paperclip/app/tools/workspace-init/dist/index.js "$@"\n' \
61+
> /usr/local/bin/paperclip-workspace-init \
62+
&& chmod +x /usr/local/bin/paperclip-workspace-init
63+
64+
USER 1000:1000
65+
WORKDIR /workspace
66+
ENTRYPOINT ["/usr/bin/tini", "--"]
67+
CMD ["/usr/local/bin/paperclip-agent-shim"]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
RUN npm install -g @anthropic-ai/claude-code@latest \
7+
&& chown -R 1000:1000 /usr/lib/node_modules \
8+
|| true
9+
10+
# @anthropic-ai/claude-code installs as 'claude', but the shim expects 'claude-code'
11+
RUN command -v claude >/dev/null 2>&1 && ln -s /usr/bin/claude /usr/bin/claude-code || true
12+
13+
USER 1000:1000
14+
15+
# Verify the CLI is on PATH for the shim's exec.LookPath
16+
RUN command -v claude-code >/dev/null 2>&1 || (echo "claude-code not on PATH"; exit 1)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
RUN npm install -g @openai/codex@latest \
7+
&& chown -R 1000:1000 /usr/lib/node_modules \
8+
|| true
9+
10+
USER 1000:1000
11+
12+
# Verify the CLI is on PATH for the shim's exec.LookPath
13+
RUN command -v codex >/dev/null 2>&1 || (echo "codex not on PATH"; exit 1)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
RUN npm install -g @google/gemini-cli@latest \
7+
&& chown -R 1000:1000 /usr/lib/node_modules \
8+
|| true
9+
10+
USER 1000:1000
11+
12+
# Verify the CLI is on PATH for the shim's exec.LookPath
13+
RUN command -v gemini >/dev/null 2>&1 || (echo "gemini not on PATH"; exit 1)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
# hermes_local is in the legacy sessioned-adapter set but has no upstream
6+
# npm package wired into Paperclip locally yet (see
7+
# packages/adapter-utils/src/session-compaction.ts vs the absent
8+
# packages/adapters/hermes-local/). This stub image preserves a cloud
9+
# runtime slot for hermes_local; whoever ports the hermes binary to
10+
# Paperclip locally adds the `npm install -g <pkg>` and the PATH check
11+
# to this Dockerfile.
12+
#
13+
# Until then, runs targeting hermes_local on the cloud adapter will boot
14+
# the container but `agent-shim` will fail with "hermes not on PATH" when
15+
# it tries to invoke the CLI.
16+
17+
USER 1000:1000
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
# opencode-ai npm package; binary on PATH is `opencode`.
7+
RUN npm install -g opencode-ai@latest \
8+
&& chown -R 1000:1000 /usr/lib/node_modules \
9+
|| true
10+
11+
USER 1000:1000
12+
13+
# Verify the CLI is on PATH for the shim's exec.LookPath
14+
RUN command -v opencode >/dev/null 2>&1 || (echo "opencode not on PATH"; exit 1)

docker/agent-runtime/Dockerfile.pi

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# syntax=docker/dockerfile:1.6
2+
ARG BASE_TAG=dev
3+
FROM paperclipinc/agent-runtime-base:${BASE_TAG}
4+
5+
USER root
6+
# pi is the multi-provider Pi coding-agent router. Verified npm package:
7+
# @mariozechner/pi-coding-agent (binary 'pi' → dist/cli.js). Same package
8+
# is used by SANDBOX_INSTALL_COMMAND in pi-local/src/index.ts.
9+
RUN npm install -g @mariozechner/pi-coding-agent@latest \
10+
&& chown -R 1000:1000 /usr/lib/node_modules \
11+
|| true
12+
13+
USER 1000:1000
14+
15+
# Verify the CLI is on PATH for the shim's exec.LookPath
16+
RUN command -v pi >/dev/null 2>&1 || (echo "pi not on PATH"; exit 1)

docker/agent-runtime/README.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Agent Runtime Image Family
2+
3+
Paperclip.inc publishes container images for remote agents. Images are named `agent-runtime-{adapterType}:{paperclipVersion}` and distributed via `ghcr.io/paperclipinc/`.
4+
5+
## Image Lineup
6+
7+
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 22 + git + tini + non-root user + shim + workspace-init.
8+
- **`agent-runtime-claude`**: Extends base with `@anthropic-ai/claude-code` CLI globally installed.
9+
- Future: Additional adapter-specific images follow the same pattern (e.g., `agent-runtime-go`, `agent-runtime-rust`).
10+
11+
## Base Image Contents
12+
13+
**OS & Runtime:**
14+
- Ubuntu 22.04
15+
- Node.js 22 (via NodeSource APT repo)
16+
- git
17+
- tini (PID-1 init, ensures signal propagation)
18+
- Non-root user `paperclip` (uid/gid 1000)
19+
20+
**Paperclip Binaries:**
21+
- `/usr/local/bin/paperclip-agent-shim` — Go binary compiled from `tools/agent-shim/`. Reads `/run/paperclip/runtime-command.json` and `syscall.Exec`s the adapter CLI.
22+
- `/usr/local/bin/paperclip-workspace-init` — Node script entry point. Used by init container to bootstrap the workspace.
23+
24+
**Defaults:**
25+
- `USER`: 1000:1000 (paperclip, non-root)
26+
- `WORKDIR`: `/workspace` — PVCs are mounted here
27+
- `ENTRYPOINT`: `/usr/bin/tini --` (PID-1 reaper, forwards signals)
28+
- `CMD`: `/usr/local/bin/paperclip-agent-shim`
29+
30+
## Building Locally
31+
32+
### Multi-architecture (amd64 + arm64)
33+
34+
```bash
35+
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl --push
36+
```
37+
38+
### Host-only (faster iteration)
39+
40+
Replace the architecture with your machine's native platform:
41+
42+
```bash
43+
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl \
44+
--set "*.platforms=linux/$(uname -m | sed s/x86_64/amd64/)" \
45+
--load
46+
```
47+
48+
### Custom tag or registry
49+
50+
```bash
51+
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl \
52+
--set "*.tags=myregistry/agent-runtime-base:mytag" \
53+
--load
54+
```
55+
56+
## Quickstart Smoke Test
57+
58+
Build and verify the `agent-runtime-claude` image runs locally:
59+
60+
```bash
61+
docker buildx bake -f docker/agent-runtime/buildx-bake.hcl \
62+
--set "*.platforms=linux/$(uname -m | sed s/x86_64/amd64/)" \
63+
--load
64+
docker run --rm ghcr.io/paperclipinc/agent-runtime-claude:dev claude-code --version
65+
```
66+
67+
## Init Container (workspace-init)
68+
69+
The init container prepares the workspace before the agent starts. It reads environment variables, bootstraps the workspace directory tree, and exits.
70+
71+
**Environment Variables:**
72+
- `PAPERCLIP_WORKSPACE_REQUEST` — JSON serialized workspace request (required)
73+
- `PAPERCLIP_WORKSPACE_ROOT` — Where to write workspace state (default: `/workspace`)
74+
- `BOOTSTRAP_TOKEN` — Authentication token for workspace API (required)
75+
- `PAPERCLIP_PUBLIC_URL` — Public endpoint for workspace callbacks (required)
76+
77+
**Failure Modes:**
78+
Missing or invalid env vars → exit code 1. Pod init never repeats; failure blocks agent startup.
79+
80+
## Agent Container (paperclip-agent-shim)
81+
82+
The main agent runs as the shim process (PID 1 under tini). The shim:
83+
84+
1. Reads `/run/paperclip/runtime-command.json` — a JSON file mounted by the Job controller
85+
2. Parses `{ command, args, ... }` — the adapter CLI and arguments
86+
3. `syscall.Exec`s the adapter process, replacing itself
87+
4. SIGTERM from kubelet propagates directly to the adapter (no process zombie)
88+
89+
**runtime-command.json Contract:**
90+
```json
91+
{
92+
"command": "claude-code",
93+
"args": ["--token", "xyz", "--workspace", "/workspace"]
94+
}
95+
```
96+
97+
The shim makes no assumptions about command structure; it is adapter-agnostic. Future adapters swap the command/args; the image remains the same.
98+
99+
## Security Model
100+
101+
- **Non-root execution** — user 1000:1000, no capability grant
102+
- **PSS Restricted compatible** — no privileged containers, no host mounts, read-only filesystem (except `/workspace` + `/tmp`)
103+
- **No secrets baked in** — API tokens, credentials come from per-Job ephemeral Secrets mounted as env vars or files
104+
- **Image signing** — cosign keyless OIDC in CI (see Task 29)
105+
106+
## Versioning Policy
107+
108+
**agent-runtime-base:**
109+
- Version tag `vX.Y.Z` published when the shim or workspace-init source changes
110+
- Includes all base layer content (OS, Node, git, tini, non-root user)
111+
112+
**agent-runtime-claude:**
113+
- Builds on top of base at the same version tag
114+
- Version tag bumps independently when a new `@anthropic-ai/claude-code` release is pinned
115+
- Currently uses `npm install @anthropic-ai/claude-code@latest` for the `dev` tag; CI workflow (Task 29) will pin exact semver versions per release
116+
117+
## Multi-arch Caveats
118+
119+
- Both amd64 and arm64 images are built in CI; local builds require `--load` on single-arch or `--push` for multi-arch
120+
- Go shim cross-compilation is automatic via `GOARCH` (see Dockerfile.base Stage 1)
121+
- Node modules are platform-agnostic; workspace-init rebuilds without issues across architectures

0 commit comments

Comments
 (0)