From 5ea8a821575db31ebe4b7dcaab150402431ad73e Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:55:11 -0500 Subject: [PATCH] fix(deploy): address review feedback on Cloudflare deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove sharp from resolution entirely (high-severity libvips CVEs, GHSA-f88m-g3jw-g9cj; unused optional wrangler dep) — unblocks the Dependency Review gate - Gate unclaimed operator invite behind a BOOTSTRAP_TOKEN secret (Greptile P1): query-param-once + cookie, constant-time compare, documented claim-then-delete flow - Serialize cold-start boots (Greptile P1): shared in-flight ensure promise per isolate + flock --nonblock in start-paperclip.sh across isolates - Pin exact npm versions in the container image (Greptile/Superagent P2) and reject mutable tags in config tests - Use request origin for PAPERCLIP_PUBLIC_URL fallback so wrangler dev http:// works (Copilot) - Declare packages list in standalone pnpm-workspace.yaml (Copilot); plain pnpm install now works without --ignore-workspace - Document Node.js 22+ requirement (Copilot/Greptile) Claude-Session: https://claude.ai/code/session_01NWpwXBqxrPZdv75ud8xszr --- deploy/cloudflare/README.md | 12 +- deploy/cloudflare/container/Dockerfile | 14 +- .../cloudflare/container/start-paperclip.sh | 5 +- deploy/cloudflare/pnpm-lock.yaml | 303 +----------------- deploy/cloudflare/pnpm-workspace.yaml | 12 +- deploy/cloudflare/src/index.ts | 79 ++++- deploy/cloudflare/src/lib.ts | 48 +++ deploy/cloudflare/test/config.test.ts | 12 +- deploy/cloudflare/test/lib.test.ts | 22 ++ docs/deploy/cloudflare.md | 36 ++- 10 files changed, 214 insertions(+), 329 deletions(-) diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index be6583a6db..7da32ffb29 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -7,8 +7,9 @@ running `paperclipai` with embedded Postgres and the local-adapter agent CLIs preinstalled. No custom domain required. ```sh -pnpm install --ignore-workspace +pnpm install npx wrangler login +npx wrangler secret put BOOTSTRAP_TOKEN # gate access until you claim the operator account npx wrangler deploy ``` @@ -30,10 +31,11 @@ troubleshooting): **[docs/deploy/cloudflare.md](../../docs/deploy/cloudflare.md) | `wrangler.jsonc` | Worker + container + Durable Object config | | `test/` | Unit tests + cross-file config consistency checks | -This package is intentionally **not** part of the pnpm workspace (same -pattern as `packages/plugins/sandbox-providers/*`) so its Cloudflare -toolchain never churns the root lockfile. Install with -`pnpm install --ignore-workspace`. +This package is intentionally **not** part of the root pnpm workspace (same +pattern as `packages/plugins/sandbox-providers/*`) — its own +`pnpm-workspace.yaml` makes it a standalone single-package workspace, so the +Cloudflare toolchain never churns the root lockfile and a plain +`pnpm install` here does the right thing. ```sh pnpm test # vitest: lib + config invariants (Dockerfile↔SDK version pin) diff --git a/deploy/cloudflare/container/Dockerfile b/deploy/cloudflare/container/Dockerfile index 327dde04e4..50fb358e00 100644 --- a/deploy/cloudflare/container/Dockerfile +++ b/deploy/cloudflare/container/Dockerfile @@ -13,13 +13,15 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Paperclip (npm release) plus the local-adapter agent CLIs, mirroring the -# root Dockerfile's production stage. +# root Dockerfile's production stage. Versions are pinned exactly so image +# rebuilds are reproducible and supply-chain review applies to a known set +# (test/config.test.ts rejects mutable tags); bump them deliberately. RUN npm install -g \ - paperclipai@latest \ - @anthropic-ai/claude-code@latest \ - @openai/codex@latest \ - opencode-ai \ - @google/gemini-cli@latest + paperclipai@2026.722.0 \ + @anthropic-ai/claude-code@2.1.218 \ + @openai/codex@0.145.0 \ + opencode-ai@1.18.4 \ + @google/gemini-cli@0.52.0 # Paperclip runtime defaults (mirrors the root Dockerfile / quadlet unit). ENV HOST=0.0.0.0 \ diff --git a/deploy/cloudflare/container/start-paperclip.sh b/deploy/cloudflare/container/start-paperclip.sh index ddcc39b1aa..b9c215a0f2 100644 --- a/deploy/cloudflare/container/start-paperclip.sh +++ b/deploy/cloudflare/container/start-paperclip.sh @@ -12,7 +12,10 @@ set -euo pipefail find /paperclip -path /paperclip/instances/default/data/storage -prune \ -o -exec chown paperclip:paperclip {} + -exec runuser -u paperclip -- bash -c ' +# Serialize boots: concurrent Worker isolates can race ensurePaperclip() and +# start this script twice. The non-blocking lock makes every duplicate exit +# immediately instead of fighting over onboarding and port 3100. +exec flock --nonblock /paperclip/.boot.lock runuser -u paperclip -- bash -c ' set -euo pipefail export HOME=/home/paperclip if [ ! -f /paperclip/instances/default/config.json ]; then diff --git a/deploy/cloudflare/pnpm-lock.yaml b/deploy/cloudflare/pnpm-lock.yaml index 516fd81055..97a6630a8c 100644 --- a/deploy/cloudflare/pnpm-lock.yaml +++ b/deploy/cloudflare/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + sharp: '-' + importers: .: @@ -103,9 +106,6 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -265,159 +265,6 @@ packages: cpu: [x64] os: [win32] - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -779,15 +626,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1011,11 +849,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -1099,102 +932,6 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.11.2 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1463,7 +1200,6 @@ snapshots: miniflare@4.20260721.0: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 undici: 7.28.0 workerd: 1.20260721.1 ws: 8.21.0 @@ -1511,39 +1247,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 - semver@7.8.5: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - siginfo@2.0.0: {} source-map-js@1.2.1: {} diff --git a/deploy/cloudflare/pnpm-workspace.yaml b/deploy/cloudflare/pnpm-workspace.yaml index df3ab17e7c..6967ff7559 100644 --- a/deploy/cloudflare/pnpm-workspace.yaml +++ b/deploy/cloudflare/pnpm-workspace.yaml @@ -2,10 +2,14 @@ # not attach it to the repo root workspace (same isolation rationale as # packages/plugins/sandbox-providers/*): the Cloudflare toolchain # (wrangler/workerd) never churns the root pnpm-lock.yaml. -# sharp stays false: it is an optional wrangler dependency (static-asset -# image processing) with no prebuilt binary on some platforms, and its -# source build fails without node-addon-api. Wrangler works without it. +packages: + - "." +# sharp is an optional wrangler dependency (static-asset image processing) +# that this Worker never uses; it is removed from resolution entirely +# because sharp@0.34.x carries high-severity libvips CVEs +# (GHSA-f88m-g3jw-g9cj) that trip dependency review. +overrides: + sharp: "-" allowBuilds: esbuild: true - sharp: false workerd: true diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index 5e0b9f54ca..a753ab6762 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -16,12 +16,16 @@ import { getSandbox, type Sandbox as SandboxType } from "@cloudflare/sandbox"; import { ARTIFACTS_BINDING, + BOOTSTRAP_COOKIE, + BOOTSTRAP_PARAM, PAPERCLIP_PORT, SANDBOX_ID, START_COMMAND, STORAGE_MOUNT_PATH, + accessDeniedPage, bootingResponse, buildPaperclipEnv, + getCookie, isMountAlreadyInUse, isPaperclipRunning, isTransientBootError, @@ -49,6 +53,13 @@ interface Env { /** Secrets (wrangler secret put …); forwarded to the container when set. */ ANTHROPIC_API_KEY?: string; DATABASE_URL?: string; + /** + * When set, every request must present this token (?bootstrap_token=…, + * which sets a cookie) — protects the unclaimed operator invite between + * first boot and the operator's first login. Delete the secret after + * claiming the account to open the login page to your team. + */ + BOOTSTRAP_TOKEN?: string; } /** @@ -58,6 +69,62 @@ interface Env { */ let paperclipEnsured = false; +/** + * Shared in-flight boot so concurrent cold-start requests in one isolate + * issue a single ensure pass instead of racing startProcess. Cross-isolate + * duplicates are additionally serialized by the flock in + * container/start-paperclip.sh — duplicates exit immediately. + */ +let ensureInFlight: Promise | null = null; + +/** Constant-time comparison via digest so token checks don't leak timing. */ +async function tokensMatch(presented: string, expected: string): Promise { + const encoder = new TextEncoder(); + const [a, b] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(presented)), + crypto.subtle.digest("SHA-256", encoder.encode(expected)), + ]); + const av = new Uint8Array(a); + const bv = new Uint8Array(b); + let diff = 0; + for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i]; + return diff === 0; +} + +/** + * Bootstrap gate: when BOOTSTRAP_TOKEN is set, only requests presenting it + * (query param once, cookie afterwards) reach Paperclip. Returns null when + * the request may proceed, otherwise the response to serve. + */ +async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promise { + if (!env.BOOTSTRAP_TOKEN) return null; + + const presented = url.searchParams.get(BOOTSTRAP_PARAM); + if (presented !== null && (await tokensMatch(presented, env.BOOTSTRAP_TOKEN))) { + // Strip the token from the URL and persist access in a cookie. + url.searchParams.delete(BOOTSTRAP_PARAM); + return new Response(null, { + status: 302, + headers: { + location: url.toString(), + "set-cookie": + `${BOOTSTRAP_COOKIE}=${encodeURIComponent(env.BOOTSTRAP_TOKEN)}; ` + + "HttpOnly; Secure; SameSite=Lax; Path=/", + }, + }); + } + + const cookie = getCookie(request.headers.get("Cookie"), BOOTSTRAP_COOKIE); + if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN))) { + return null; + } + + return new Response(accessDeniedPage(), { + status: 401, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }); +} + async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): Promise { const processes = await sandbox.listProcesses(); if (isPaperclipRunning(processes)) return; @@ -76,7 +143,9 @@ async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): await sandbox.startProcess(START_COMMAND, { env: buildPaperclipEnv({ - publicUrl: env.PAPERCLIP_PUBLIC_URL || `https://${requestUrl.host}`, + // origin (not a hardcoded https:// prefix) so wrangler dev's http:// + // origin round-trips correctly and auth cookies behave locally. + publicUrl: env.PAPERCLIP_PUBLIC_URL || requestUrl.origin, deploymentMode: env.PAPERCLIP_DEPLOYMENT_MODE, deploymentExposure: env.PAPERCLIP_DEPLOYMENT_EXPOSURE, anthropicApiKey: env.ANTHROPIC_API_KEY, @@ -101,9 +170,15 @@ export default { const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); const url = new URL(request.url); + const denied = await enforceBootstrapGate(request, env, url); + if (denied) return denied; + try { if (!paperclipEnsured) { - await ensurePaperclip(sandbox, env, url); + ensureInFlight ??= ensurePaperclip(sandbox, env, url).finally(() => { + ensureInFlight = null; + }); + await ensureInFlight; paperclipEnsured = true; } diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts index 64beff22f1..3b578e2a9a 100644 --- a/deploy/cloudflare/src/lib.ts +++ b/deploy/cloudflare/src/lib.ts @@ -73,6 +73,54 @@ export function isWebSocketUpgrade(headers: Headers): boolean { return headers.get("Upgrade")?.toLowerCase() === "websocket"; } +/** Cookie set once a visitor presents the bootstrap token. */ +export const BOOTSTRAP_COOKIE = "paperclip_bootstrap"; + +/** Query parameter used to present the bootstrap token on first visit. */ +export const BOOTSTRAP_PARAM = "bootstrap_token"; + +/** Minimal cookie-header lookup (no parsing library needed for one value). */ +export function getCookie(cookieHeader: string | null, name: string): string | undefined { + if (!cookieHeader) return undefined; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return undefined; +} + +/** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */ +export function accessDeniedPage(): string { + return ` + + + + +Paperclip — access restricted + + + +
+

Access restricted

+

This Paperclip deployment is gated by a bootstrap token.

+

Open the URL with ?${BOOTSTRAP_PARAM}=<your token> — + the value you set with wrangler secret put BOOTSTRAP_TOKEN.

+

Once the operator account is claimed, the operator can remove the gate + with wrangler secret delete BOOTSTRAP_TOKEN.

+
+ +`; +} + /** True when a live Paperclip boot process already exists in the sandbox. */ export function isPaperclipRunning(processes: ProcessLike[]): boolean { return processes.some( diff --git a/deploy/cloudflare/test/config.test.ts b/deploy/cloudflare/test/config.test.ts index 2137348834..e3346b38ae 100644 --- a/deploy/cloudflare/test/config.test.ts +++ b/deploy/cloudflare/test/config.test.ts @@ -69,9 +69,11 @@ describe("container image", () => { expect(dockerfile).toContain(`EXPOSE ${PAPERCLIP_PORT}`); }); - it("installs Paperclip and the agent CLIs", () => { - expect(dockerfile).toContain("paperclipai@latest"); - expect(dockerfile).toContain("@anthropic-ai/claude-code"); + it("installs Paperclip and the agent CLIs with exact version pins", () => { + expect(dockerfile).toMatch(/paperclipai@\d+\.\d+\.\d+/); + expect(dockerfile).toMatch(/@anthropic-ai\/claude-code@\d+\.\d+\.\d+/); + // Mutable tags make image rebuilds non-reproducible and un-reviewable. + expect(dockerfile).not.toContain("@latest"); }); it("pins the paperclip uid the R2 mount options rely on", () => { @@ -87,4 +89,8 @@ describe("boot script", () => { expect(script).toContain(`-path ${STORAGE_MOUNT_PATH} -prune`); expect(script).not.toMatch(/chown -R paperclip:paperclip \/paperclip\s*$/m); }); + + it("serializes duplicate boots with a non-blocking lock", () => { + expect(script).toContain("flock --nonblock"); + }); }); diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts index 3db3b41b1f..ae2cab17ec 100644 --- a/deploy/cloudflare/test/lib.test.ts +++ b/deploy/cloudflare/test/lib.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + BOOTSTRAP_PARAM, PAPERCLIP_PORT, PAPERCLIP_UID, START_COMMAND, STORAGE_MOUNT_PATH, + accessDeniedPage, + getCookie, bootingPage, bootingResponse, buildPaperclipEnv, @@ -117,6 +120,25 @@ describe("transient boot detection", () => { }); }); +describe("bootstrap gate helpers", () => { + it("extracts a single cookie value", () => { + expect(getCookie("a=1; paperclip_bootstrap=tok; b=2", "paperclip_bootstrap")).toBe("tok"); + expect(getCookie("paperclip_bootstrap=tok", "paperclip_bootstrap")).toBe("tok"); + }); + + it("returns undefined for missing header, missing cookie, or name prefixes", () => { + expect(getCookie(null, "paperclip_bootstrap")).toBeUndefined(); + expect(getCookie("other=1", "paperclip_bootstrap")).toBeUndefined(); + expect(getCookie("xpaperclip_bootstrap=evil", "paperclip_bootstrap")).toBeUndefined(); + }); + + it("access-denied page names the param and secret", () => { + const html = accessDeniedPage(); + expect(html).toContain(BOOTSTRAP_PARAM); + expect(html).toContain("BOOTSTRAP_TOKEN"); + }); +}); + describe("storage mount", () => { it("targets Paperclip's local_disk storage path, never the DB dir", () => { expect(STORAGE_MOUNT_PATH).toBe("/paperclip/instances/default/data/storage"); diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md index 3186eb814b..68fb197a74 100644 --- a/docs/deploy/cloudflare.md +++ b/docs/deploy/cloudflare.md @@ -61,26 +61,45 @@ Notes: available on the free tier) - [Docker](https://docs.docker.com/get-docker/) running locally (wrangler builds the container image and, for `wrangler dev`, runs it) -- Node.js 20+ and pnpm +- Node.js 22+ (required by the pinned wrangler toolchain) and pnpm ## Deploy ```sh cd deploy/cloudflare -pnpm install --ignore-workspace +pnpm install npx wrangler login + +# strongly recommended: gate the deployment until you claim the operator +# account — any value you choose, e.g. `openssl rand -hex 16` +npx wrangler secret put BOOTSTRAP_TOKEN + npx wrangler deploy # optional: give in-container agents an API key npx wrangler secret put ANTHROPIC_API_KEY ``` -Then open the printed `https://paperclip-sandbox..workers.dev` -URL. The **first request** provisions the container and onboards Paperclip +Then open +`https://paperclip-sandbox..workers.dev/?bootstrap_token=`. +The **first request** provisions the container and onboards Paperclip (a minute or two) — you'll see a self-refreshing status page until the app is -up. Paperclip boots in `authenticated` mode with a pending bootstrap invite; -the first login claims the operator account, so open the URL yourself promptly -after deploying. +up. + +Paperclip boots in `authenticated` mode with a pending bootstrap invite, and +**the first visitor to reach the app can claim the operator account**. The +`BOOTSTRAP_TOKEN` gate exists to make sure that visitor is you: without the +token (query param once; cookie afterwards), the Worker serves a 401 and +nothing reaches Paperclip. After you claim the account, remove the gate so +your team can reach the login page: + +```sh +npx wrangler secret delete BOOTSTRAP_TOKEN +``` + +If you skip the token, deploy and claim the account immediately — an +unclaimed invite on a public `workers.dev` URL is claimable by anyone who +finds it. ## Configuration @@ -91,6 +110,7 @@ Set via `vars` in `wrangler.jsonc` or `wrangler secret put`: | `PAPERCLIP_PUBLIC_URL` | var | request origin | Public URL Paperclip advertises | | `PAPERCLIP_DEPLOYMENT_MODE` | var | `authenticated` | See [Deployment Modes](/deploy/deployment-modes) | | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | var | `private` | Embedded Postgres currently requires `private`; use `public` only with an external `DATABASE_URL` | +| `BOOTSTRAP_TOKEN` | secret | — | Gates all access until the operator account is claimed (see Deploy) | | `ANTHROPIC_API_KEY` | secret | — | Forwarded to in-container agent CLIs | | `DATABASE_URL` | secret | — | External Postgres (strongly recommended, see above) | | `ARTIFACTS` | R2 binding | — | Durable attachment storage (see above) | @@ -117,7 +137,7 @@ want real memory; shrink with care. ```sh cd deploy/cloudflare -pnpm install --ignore-workspace +pnpm install pnpm dev # wrangler dev — builds and runs the container via Docker ```