From 95692b7785c794dab4d02491a3117702b90e3e4f Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:03:01 +0300 Subject: [PATCH] fix(docker): forward tool action signing secret in Compose (#15202) ## Thinking Path > - Paperclip can run through Docker Compose. > - Approval-gated tools need an independent signing secret. > - The guide sets that secret before starting Compose. > - Neither Compose file passes it into the container. > - This pull request forwards the operator's configured secret. > - Documented setup can issue signed tool-action approvals. ## Linked Issues or Issue Description Fixes: #15064 ## What Changed - Forward PAPERCLIP_TOOL_ACTION_SIGNING_SECRET in both shipped Compose environments. - Document persistent deployment secrets and reuse on restart. - Explain that it is independent from the session secret. - Keep an empty default so deployments without approval-gated tools retain their existing startup behavior. ## Verification - Docker Compose config before the fix drops a supplied synthetic secret in both files. - After the fix, both preserve the supplied value. - Both also resolve successfully when the optional signing secret is absent. - Used --env-file /dev/null and synthetic values. No containers were started. - `node --test scripts/docker-compose-secrets.test.mjs`: 2 passed. - The Docker context CI lane runs this integration test. - git diff --check passes. ## Risks The value is visible to the container as intended. Operators must protect it as a secret. This does not change admission rules or require a new startup value for existing deployments. ## Model Used OpenAI Codex, based on GPT-6, assisted with source review, implementation and local test execution. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted local checks and full remote CI pass) - [x] I have added or updated tests where applicable (Hermes regression / real Compose interpolation checks) - [x] I have updated relevant documentation to reflect my changes (Hermes has no configuration changes; Compose documentation is updated) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .github/workflows/pr-trusted.yml | 8 +++++ doc/DOCKER.md | 27 +++++++++++++--- docker/docker-compose.quickstart.yml | 6 +++- docker/docker-compose.yml | 2 ++ scripts/docker-compose-secrets.test.mjs | 42 +++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 scripts/docker-compose-secrets.test.mjs 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 }); + } + }); +}