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

+30
View File
@@ -0,0 +1,30 @@
# AWS ECS Fargate deployment environment
# Copy to .env.aws and fill in values before deploying
#
# Secrets (DATABASE_URL, BETTER_AUTH_SECRET, ANTHROPIC_API_KEY, OPENAI_API_KEY,
# GITHUB_TOKEN) are injected via AWS Secrets Manager — do NOT set them here.
# Deployment mode
PAPERCLIP_DEPLOYMENT_MODE=authenticated
PAPERCLIP_DEPLOYMENT_EXPOSURE=public
PAPERCLIP_PUBLIC_URL=https://paperclip.example.com
# Server
HOST=0.0.0.0
PORT=3100
NODE_ENV=production
SERVE_UI=true
# Paperclip paths
PAPERCLIP_HOME=/paperclip
PAPERCLIP_INSTANCE_ID=default
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json
# Auto-apply migrations on startup
PAPERCLIP_MIGRATION_AUTO_APPLY=true
# Enable heartbeat scheduler for remote agents
HEARTBEAT_SCHEDULER_ENABLED=true
# Post-deploy hardening (uncomment after first user signs up)
# PAPERCLIP_AUTH_DISABLE_SIGN_UP=true
+40
View File
@@ -0,0 +1,40 @@
FROM ubuntu:24.04
ARG NODE_MAJOR=20
ARG PAPERCLIPAI_VERSION=latest
ARG HOST_UID=10001
ENV DEBIAN_FRONTEND=noninteractive \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_OPEN_ON_LISTEN=false \
HOST=0.0.0.0 \
PORT=3100 \
HOME=/home/paperclip \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NPM_CONFIG_UPDATE_NOTIFIER=false \
NODE_MAJOR=${NODE_MAJOR} \
PAPERCLIPAI_VERSION=${PAPERCLIPAI_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg locales \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& locale-gen en_US.UTF-8 \
&& groupadd --gid 10001 paperclip \
&& useradd --create-home --shell /bin/bash --uid "${HOST_UID}" --gid 10001 paperclip \
&& mkdir -p /paperclip /home/paperclip/workspace \
&& chown -R paperclip:paperclip /paperclip /home/paperclip \
&& rm -rf /var/lib/apt/lists/*
VOLUME ["/paperclip"]
WORKDIR /home/paperclip/workspace
EXPOSE 3100
USER paperclip
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]
+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"
}
}
+18
View File
@@ -0,0 +1,18 @@
services:
paperclip:
build:
context: ..
dockerfile: Dockerfile
ports:
- "${PAPERCLIP_PORT:-3100}:3100"
environment:
HOST: "0.0.0.0"
PAPERCLIP_HOME: "/paperclip"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes:
- "${PAPERCLIP_DATA_DIR:-../data/docker-paperclip}:/paperclip"
@@ -0,0 +1,33 @@
services:
review:
build:
context: ..
dockerfile: docker/untrusted-review/Dockerfile
init: true
tty: true
stdin_open: true
working_dir: /work
environment:
HOME: "/home/reviewer"
CODEX_HOME: "/home/reviewer/.codex"
CLAUDE_HOME: "/home/reviewer/.claude"
PAPERCLIP_HOME: "/home/reviewer/.paperclip-review"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
ports:
- "${REVIEW_PAPERCLIP_PORT:-3100}:3100"
- "${REVIEW_VITE_PORT:-5173}:5173"
volumes:
- review-home:/home/reviewer
- review-work:/work
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:mode=1777,size=1g
volumes:
review-home:
review-work:
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: paperclip
POSTGRES_PASSWORD: paperclip
POSTGRES_DB: paperclip
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"]
interval: 2s
timeout: 5s
retries: 30
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
server:
build:
context: ..
dockerfile: Dockerfile
ports:
- "3100:3100"
environment:
DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip
PORT: "3100"
SERVE_UI: "true"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes:
- paperclip-data:/paperclip
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
paperclip-data:
+90
View File
@@ -0,0 +1,90 @@
{
"family": "paperclip-server",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "2048",
"memory": "4096",
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-execution",
"taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-task",
"containerDefinitions": [
{
"name": "paperclip-server",
"image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/paperclip-server:latest",
"essential": true,
"portMappings": [
{
"containerPort": 3100,
"protocol": "tcp"
}
],
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "HOST", "value": "0.0.0.0" },
{ "name": "PORT", "value": "3100" },
{ "name": "SERVE_UI", "value": "true" },
{ "name": "PAPERCLIP_HOME", "value": "/paperclip" },
{ "name": "PAPERCLIP_INSTANCE_ID", "value": "default" },
{ "name": "PAPERCLIP_CONFIG", "value": "/paperclip/instances/default/config.json" },
{ "name": "PAPERCLIP_DEPLOYMENT_MODE", "value": "authenticated" },
{ "name": "PAPERCLIP_DEPLOYMENT_EXPOSURE", "value": "public" },
{ "name": "PAPERCLIP_PUBLIC_URL", "value": "https://<DOMAIN>" },
{ "name": "PAPERCLIP_MIGRATION_AUTO_APPLY", "value": "true" },
{ "name": "HEARTBEAT_SCHEDULER_ENABLED", "value": "true" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/database-url"
},
{
"name": "BETTER_AUTH_SECRET",
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/better-auth-secret"
},
{
"name": "ANTHROPIC_API_KEY",
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/anthropic-api-key"
},
{
"name": "OPENAI_API_KEY",
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/openai-api-key"
},
{
"name": "GITHUB_TOKEN",
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/github-token"
}
],
"mountPoints": [
{
"sourceVolume": "paperclip-data",
"containerPath": "/paperclip",
"readOnly": false
}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3100/api/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/paperclip",
"awslogs-region": "<REGION>",
"awslogs-stream-prefix": "server"
}
}
}
],
"volumes": [
{
"name": "paperclip-data",
"efsVolumeConfiguration": {
"fileSystemId": "<EFS_ID>",
"rootDirectory": "/",
"transitEncryption": "ENABLED"
}
}
]
}
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1.6
ARG HERMES_VERSION=0.17.0
FROM node:22-bookworm-slim
ARG HERMES_VERSION
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
python3-pip \
tini \
&& rm -rf /var/lib/apt/lists/*
# The npm package is a pinned bridge that exposes the `hermes` binary. Install
# the matching Python package globally first so the non-root runtime user can
# import the upstream Hermes modules at container start.
RUN python3 -m pip install --break-system-packages --no-cache-dir "hermes-agent[anthropic]==${HERMES_VERSION}" "aiohttp==3.13.4" \
&& rm -f /usr/local/bin/hermes /usr/local/bin/hermes-agent \
&& npm install -g --ignore-scripts "hermes-agent@${HERMES_VERSION}" \
&& python3 -c "import hermes_cli.main" \
&& command -v hermes >/dev/null
RUN useradd --create-home --shell /bin/bash --uid 10001 hermes \
&& mkdir -p /home/hermes/.hermes /home/hermes/workspace \
&& chown -R hermes:hermes /home/hermes
COPY entrypoint.sh /usr/local/bin/hermes-gateway-entrypoint
RUN chmod 0755 /usr/local/bin/hermes-gateway-entrypoint
USER hermes
WORKDIR /home/hermes/workspace
ENV HOME=/home/hermes \
HERMES_HOME=/home/hermes/.hermes \
XDG_CONFIG_HOME=/home/hermes/.config \
XDG_CACHE_HOME=/home/hermes/.cache \
XDG_DATA_HOME=/home/hermes/.local/share \
API_SERVER_ENABLED=true \
API_SERVER_HOST=0.0.0.0 \
API_SERVER_PORT=8642 \
NO_COLOR=1
EXPOSE 8642
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/hermes-gateway-entrypoint"]
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
log() {
echo "[hermes-gateway-smoke] $*"
}
hash_prefix() {
local value="$1"
if command -v sha256sum >/dev/null 2>&1; then
printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}'
elif command -v shasum >/dev/null 2>&1; then
printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}'
else
printf "unavailable"
fi
}
generate_key() {
node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'
}
export HOME="${HOME:-/home/hermes}"
export HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME}/.config}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}"
export XDG_DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
export API_SERVER_HOST="${API_SERVER_HOST:-0.0.0.0}"
export API_SERVER_PORT="${API_SERVER_PORT:-8642}"
export NO_COLOR="${NO_COLOR:-1}"
if [[ "${API_SERVER_ENABLED}" != "true" ]]; then
log "forcing API_SERVER_ENABLED=true for gateway smoke"
export API_SERVER_ENABLED=true
fi
if [[ -z "${API_SERVER_KEY:-}" ]]; then
API_SERVER_KEY="$(generate_key)"
export API_SERVER_KEY
log "generated API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}"
else
log "using provided API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}"
fi
mkdir -p "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$HOME/workspace"
chmod 0700 "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" || true
log "HOME=${HOME}"
log "HERMES_HOME=${HERMES_HOME}"
log "workspace=$(pwd)"
log "API_SERVER_HOST=${API_SERVER_HOST}"
log "API_SERVER_PORT=${API_SERVER_PORT}"
log "state listing hash=$(find "$HERMES_HOME" -maxdepth 2 -type f -print 2>/dev/null | sort | sha256sum | awk '{print substr($1,1,12)}')"
exec hermes gateway run --replace --accept-hooks "$@"
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY server.mjs /app/server.mjs
EXPOSE 8787
CMD ["node", "/app/server.mjs"]
+103
View File
@@ -0,0 +1,103 @@
import http from "node:http";
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook";
const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || "";
const maxBodyBytes = 1_000_000;
const maxEvents = 200;
const events = [];
let nextId = 1;
function writeJson(res, status, payload) {
res.statusCode = status;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(payload));
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > maxBodyBytes) {
reject(new Error("payload_too_large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
function trimEvents() {
if (events.length <= maxEvents) return;
events.splice(0, events.length - maxEvents);
}
const server = http.createServer(async (req, res) => {
const method = req.method ?? "GET";
const url = req.url ?? "/";
if (method === "GET" && url === "/health") {
writeJson(res, 200, { ok: true, webhookPath, events: events.length });
return;
}
if (method === "GET" && url === "/events") {
writeJson(res, 200, { count: events.length, events });
return;
}
if (method === "POST" && url === "/reset") {
events.length = 0;
writeJson(res, 200, { ok: true });
return;
}
if (method === "POST" && url === webhookPath) {
const authorization = req.headers.authorization ?? "";
if (expectedAuthHeader && authorization !== expectedAuthHeader) {
writeJson(res, 401, { error: "unauthorized" });
return;
}
try {
const raw = await readBody(req);
let body = null;
try {
body = raw.length > 0 ? JSON.parse(raw) : null;
} catch {
body = { raw };
}
const event = {
id: `evt-${nextId++}`,
receivedAt: new Date().toISOString(),
method,
path: url,
authorizationPresent: Boolean(authorization),
body,
};
events.push(event);
trimEvents();
writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length });
} catch (err) {
const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500;
writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" });
}
return;
}
writeJson(res, 404, { error: "not_found" });
});
server.listen(port, "0.0.0.0", () => {
// eslint-disable-next-line no-console
console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`);
});
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=PostgreSQL for Paperclip
[Container]
Image=docker.io/library/postgres:17-alpine
ContainerName=paperclip-db
Pod=paperclip.pod
Volume=paperclip-pgdata:/var/lib/postgresql/data
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h localhost || exit 1
HealthInterval=15s
HealthTimeout=5s
HealthRetries=5
[Service]
Restart=on-failure
TimeoutStartSec=60
[Install]
WantedBy=default.target
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=Paperclip AI Agent Orchestrator
Requires=paperclip-db.service
After=paperclip-db.service
[Container]
Image=paperclip-local
ContainerName=paperclip
Pod=paperclip.pod
Volume=%h/.local/share/paperclip:/paperclip:Z
Environment=HOST=0.0.0.0
Environment=PAPERCLIP_HOME=/paperclip
Environment=PAPERCLIP_DEPLOYMENT_MODE=authenticated
Environment=PAPERCLIP_DEPLOYMENT_EXPOSURE=private
Environment=PAPERCLIP_PUBLIC_URL=http://localhost:3100
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
[Service]
Restart=on-failure
TimeoutStartSec=120
[Install]
WantedBy=default.target
+3
View File
@@ -0,0 +1,3 @@
[Pod]
PodName=paperclip
PublishPort=3100:3100
+44
View File
@@ -0,0 +1,44 @@
FROM node:lts-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
fd-find \
gh \
git \
jq \
less \
openssh-client \
procps \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
RUN corepack enable \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
RUN useradd --create-home --shell /bin/bash reviewer
ENV HOME=/home/reviewer \
CODEX_HOME=/home/reviewer/.codex \
CLAUDE_HOME=/home/reviewer/.claude \
PAPERCLIP_HOME=/home/reviewer/.paperclip-review \
PNPM_HOME=/home/reviewer/.local/share/pnpm \
PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WORKDIR /work
COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr
RUN chmod +x /usr/local/bin/review-checkout-pr \
&& mkdir -p /work \
&& chown -R reviewer:reviewer /work
USER reviewer
EXPOSE 3100 5173
CMD ["bash", "-l"]
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: review-checkout-pr <owner/repo|github-url> <pr-number> [checkout-dir]
Examples:
review-checkout-pr paperclipai/paperclip 432
review-checkout-pr https://github.com/paperclipai/paperclip.git 432
EOF
}
if [[ $# -lt 2 || $# -gt 3 ]]; then
usage >&2
exit 1
fi
normalize_repo_slug() {
local raw="$1"
raw="${raw#git@github.com:}"
raw="${raw#ssh://git@github.com/}"
raw="${raw#https://github.com/}"
raw="${raw#http://github.com/}"
raw="${raw%.git}"
printf '%s\n' "${raw#/}"
}
repo_slug="$(normalize_repo_slug "$1")"
pr_number="$2"
if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then
echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2
exit 1
fi
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
echo "PR number must be numeric, got: $pr_number" >&2
exit 1
fi
repo_key="${repo_slug//\//-}"
mirror_dir="/work/repos/${repo_key}"
checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}"
pr_ref="refs/remotes/origin/pr/${pr_number}"
mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")"
if [[ ! -d "$mirror_dir/.git" ]]; then
if command -v gh >/dev/null 2>&1; then
gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none
else
git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir"
fi
fi
git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}"
if [[ -e "$checkout_dir" ]]; then
printf '%s\n' "$checkout_dir"
exit 0
fi
git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null
printf '%s\n' "$checkout_dir"