mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
commit
98df214917
3422 files changed
+1614966
No files matched your search
@@ -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
|
||||
@@ -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\""]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
Executable
+56
@@ -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 "$@"
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.mjs /app/server.mjs
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "/app/server.mjs"]
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[Pod]
|
||||
PodName=paperclip
|
||||
PublishPort=3100:3100
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user