fix(deploy): address review feedback on Cloudflare deployment

- 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
This commit is contained in:
Daniel Bodnar 2026-07-23 02:55:11 -05:00
parent 26687784b7
commit 5ea8a82157
No known key found for this signature in database
10 changed files with 214 additions and 329 deletions

View File

@ -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)

View File

@ -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 \

View File

@ -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

View File

@ -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: {}

View File

@ -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

View File

@ -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<void> | null = null;
/** Constant-time comparison via digest so token checks don't leak timing. */
async function tokensMatch(presented: string, expected: string): Promise<boolean> {
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<Response | null> {
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<void> {
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;
}

View File

@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Paperclip access restricted</title>
<style>
:root{color-scheme:light dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,sans-serif;background:#0d1017;color:#e6e6e6}
main{max-width:34rem;padding:2rem}
h1{font-size:1.1rem;font-weight:600;margin:0 0 .5rem}
p{margin:.35rem 0;color:#9aa4bf;font-size:.9rem}
code{color:#7dd3fc}
</style>
</head>
<body>
<main>
<h1>Access restricted</h1>
<p>This Paperclip deployment is gated by a bootstrap token.</p>
<p>Open the URL with <code>?${BOOTSTRAP_PARAM}=&lt;your token&gt;</code>
the value you set with <code>wrangler secret put BOOTSTRAP_TOKEN</code>.</p>
<p>Once the operator account is claimed, the operator can remove the gate
with <code>wrangler secret delete BOOTSTRAP_TOKEN</code>.</p>
</main>
</body>
</html>`;
}
/** True when a live Paperclip boot process already exists in the sandbox. */
export function isPaperclipRunning(processes: ProcessLike[]): boolean {
return processes.some(

View File

@ -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");
});
});

View File

@ -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");

View File

@ -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.<your-subdomain>.workers.dev`
URL. The **first request** provisions the container and onboards Paperclip
Then open
`https://paperclip-sandbox.<your-subdomain>.workers.dev/?bootstrap_token=<your 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
```