diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index e225da5cebd..d870c77d9eb 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -572,6 +572,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Set up Docker Compose + uses: docker/setup-compose-action@54042514f505b273907334ae2b9cdbb9a0213c1a # v2 + with: + version: v5.5.1 + + - name: Verify Compose signing secret forwarding + run: node --test scripts/docker-compose-secrets.test.mjs + # Same .dockerignore semantics as the real image builds: a # context-slimming change that strips a committed build input must # fail here, on the pull request, instead of failing every diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 507814c483a..2870a742db8 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -96,14 +96,27 @@ All persisted under your bind mount (`./data/docker-paperclip` in the example ab ## Docker Compose +### Persistent signing secrets + +Before the first launch, generate two independent values with `openssl rand -hex 32` +and save them in `docker/.env`. Keep any existing deployment's values unchanged: + +```dotenv +BETTER_AUTH_SECRET= +PAPERCLIP_TOOL_ACTION_SIGNING_SECRET= +``` + +Restrict this file to its owner (`chmod 600 docker/.env`) and keep it out of version +control. Compose reads it for both commands below. Reuse the values on every +restart. Rotate the tool-action secret only after outstanding approvals are +resolved, because pending signatures use the previous value. + ### Quickstart (embedded SQLite) Single container, no external database. Data persists via a bind mount. ```sh -BETTER_AUTH_SECRET=$(openssl rand -hex 32) \ -PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \ - docker compose -f docker/docker-compose.quickstart.yml up --build +docker compose -f docker/docker-compose.quickstart.yml up --build ``` Defaults: @@ -129,12 +142,16 @@ Pass `OPENAI_API_KEY` and/or `ANTHROPIC_API_KEY` to enable local adapter runs. Paperclip server + PostgreSQL 17. The database is health-checked before the server starts. ```sh -BETTER_AUTH_SECRET=$(openssl rand -hex 32) \ - docker compose -f docker/docker-compose.yml up --build +docker compose -f docker/docker-compose.yml up --build ``` PostgreSQL data persists in a named Docker volume (`pgdata`). Paperclip data persists in `paperclip-data`. +Both Compose files pass `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to the server. +Set it to enable signed tool-action approvals, and keep it independent from +`BETTER_AUTH_SECRET`. Leaving it unset preserves startup without approval-gated +tool actions; those calls report `signing_secret_unconfigured`. + ### Untrusted PR review Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See `doc/UNTRUSTED-PR-REVIEW.md` for the full workflow. diff --git a/docker/docker-compose.quickstart.yml b/docker/docker-compose.quickstart.yml index 8123f5efb71..25b56f30237 100644 --- a/docker/docker-compose.quickstart.yml +++ b/docker/docker-compose.quickstart.yml @@ -2,7 +2,9 @@ # Copy this file and set the required env vars before running. # # cd docker -# export BETTER_AUTH_SECRET="$(openssl rand -hex 32)" +# Set BETTER_AUTH_SECRET and PAPERCLIP_TOOL_ACTION_SIGNING_SECRET in .env +# once, using a separate `openssl rand -hex 32` value for each. Reuse them +# on restarts; do not replace existing deployment secrets. # export ANTHROPIC_API_KEY="sk-ant-..." # docker compose -f docker-compose.quickstart.yml up --build # @@ -41,6 +43,8 @@ services: # ── Required secret for session signing (generate with: openssl rand -hex 32) ── BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" + # Independent secret for signed approval-gated tool actions. + PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "${PAPERCLIP_TOOL_ACTION_SIGNING_SECRET:-}" volumes: # Persistent data directory (database, agent workspaces, uploads) - "${PAPERCLIP_DATA_DIR:-../data/docker-paperclip}:/paperclip" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 03e58dfc52e..06643657122 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -40,6 +40,8 @@ services: 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}" + # Independent secret for signed approval-gated tool actions. + PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "${PAPERCLIP_TOOL_ACTION_SIGNING_SECRET:-}" volumes: - paperclip-data:/paperclip depends_on: diff --git a/scripts/docker-compose-secrets.test.mjs b/scripts/docker-compose-secrets.test.mjs new file mode 100644 index 00000000000..4704379f31c --- /dev/null +++ b/scripts/docker-compose-secrets.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const baseEnv = Object.fromEntries( + ["PATH", "HOME", "SystemRoot", "ComSpec", "TEMP", "TMP", "USERPROFILE"] + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]]), +); +const compose = spawnSync("docker", ["compose", "version"], { env: baseEnv }); +assert.equal(compose.status, 0, "Docker Compose is required for this integration check."); + +for (const [file, service] of [ + ["docker/docker-compose.quickstart.yml", "paperclip"], + ["docker/docker-compose.yml", "server"], +]) { + test(`${file} forwards the independent tool signing secret`, () => { + const temporary = mkdtempSync(path.join(os.tmpdir(), "paperclip-compose-secrets-")); + const envFile = path.join(temporary, "empty.env"); + writeFileSync(envFile, ""); + try { + for (const signingSecret of ["test-tool-signing-secret", undefined]) { + const env = { ...baseEnv, BETTER_AUTH_SECRET: "test-session-secret" }; + if (signingSecret !== undefined) env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = signingSecret; + const result = spawnSync("docker", [ + "compose", "--env-file", envFile, "-f", file, "config", "--format", "json", + ], { cwd: repoRoot, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const containerEnv = JSON.parse(result.stdout).services[service].environment; + assert.equal(containerEnv.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET, signingSecret ?? ""); + assert.equal(containerEnv.BETTER_AUTH_SECRET, "test-session-secret"); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); +}