fix: fail closed on unusable Codex auth during sandbox auth merge

When reusing a Codex sandbox, the workspace-restore step now performs
an auth-merge check before adopting the sandbox's auth.json. The merge
fails closed when:

- host or sandbox Codex auth is unusable (missing token or account)
- the subscription payload shapes differ between host and sandbox
- subscription account identities do not match

Additional hardening: node absence on PATH during the embedded decision
script now exits non-zero instead of silently falling through. Co-change
comments link parseAuth (the embedded script) to hasUsableAuthPayload
(codex-home.ts) so a future auth-format change cannot silently diverge
between the two implementations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold KimandPaperclip committed 2026-07-09 10:08:57 +00:00
commit 98df214917
3422 files changed
+1614966

No files matched your search

+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-hermes`**: Dockerfile included in the bake group, not in the default publish scope (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.
+91
View File
@@ -0,0 +1,91 @@
group "default" {
targets = ["base", "claude", "codex", "gemini", "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 "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"
}
}