From 406d3a720f4fbfa41c02316929d384f4c22b5d35 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:01:07 -0500 Subject: [PATCH 1/5] feat(deploy): Cloudflare Workers + Sandbox container deployment path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds deploy/cloudflare: a Worker that serves a full Paperclip instance from a Cloudflare Sandbox container on the Worker's own origin (HTTP + WebSockets via containerFetch/wsConnect — no custom domain required), optional credential-less R2 FUSE mount for durable attachment storage, docs, unit tests, and cross-file config invariants. Claude-Session: https://claude.ai/code/session_01NWpwXBqxrPZdv75ud8xszr --- deploy/cloudflare/.gitignore | 5 + deploy/cloudflare/README.md | 41 + deploy/cloudflare/container/Dockerfile | 49 + .../cloudflare/container/start-paperclip.sh | 22 + deploy/cloudflare/package.json | 33 + deploy/cloudflare/pnpm-lock.yaml | 1663 +++++++++++++++++ deploy/cloudflare/pnpm-workspace.yaml | 11 + deploy/cloudflare/src/index.ts | 130 ++ deploy/cloudflare/src/lib.ts | 180 ++ deploy/cloudflare/test/config.test.ts | 90 + deploy/cloudflare/test/lib.test.ts | 154 ++ deploy/cloudflare/tsconfig.json | 15 + deploy/cloudflare/vitest.config.ts | 8 + deploy/cloudflare/wrangler.jsonc | 43 + docs/deploy/cloudflare.md | 144 ++ docs/docs.json | 1 + 16 files changed, 2589 insertions(+) create mode 100644 deploy/cloudflare/.gitignore create mode 100644 deploy/cloudflare/README.md create mode 100644 deploy/cloudflare/container/Dockerfile create mode 100644 deploy/cloudflare/container/start-paperclip.sh create mode 100644 deploy/cloudflare/package.json create mode 100644 deploy/cloudflare/pnpm-lock.yaml create mode 100644 deploy/cloudflare/pnpm-workspace.yaml create mode 100644 deploy/cloudflare/src/index.ts create mode 100644 deploy/cloudflare/src/lib.ts create mode 100644 deploy/cloudflare/test/config.test.ts create mode 100644 deploy/cloudflare/test/lib.test.ts create mode 100644 deploy/cloudflare/tsconfig.json create mode 100644 deploy/cloudflare/vitest.config.ts create mode 100644 deploy/cloudflare/wrangler.jsonc create mode 100644 docs/deploy/cloudflare.md diff --git a/deploy/cloudflare/.gitignore b/deploy/cloudflare/.gitignore new file mode 100644 index 0000000000..bf2a55e0cd --- /dev/null +++ b/deploy/cloudflare/.gitignore @@ -0,0 +1,5 @@ +# Wrangler local state (miniflare D1/KV/DO/R2 sqlite files) — never commit. +.wrangler/ +# Local secrets for `wrangler dev`. +.dev.vars +node_modules/ diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md new file mode 100644 index 0000000000..be6583a6db --- /dev/null +++ b/deploy/cloudflare/README.md @@ -0,0 +1,41 @@ +# Paperclip on Cloudflare + +Deploys a full Paperclip instance to Cloudflare Workers: the Worker proxies +your `*.workers.dev` origin (HTTP + WebSockets) into a +[Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) container +running `paperclipai` with embedded Postgres and the local-adapter agent CLIs +preinstalled. No custom domain required. + +```sh +pnpm install --ignore-workspace +npx wrangler login +npx wrangler deploy +``` + +> ⚠️ Container disk is **ephemeral** — for any data you want to keep, set an +> external database first (`npx wrangler secret put DATABASE_URL`) and +> optionally enable the R2 attachments mount (see `wrangler.jsonc`). + +Full operator guide (prerequisites, configuration, durability, costs, +troubleshooting): **[docs/deploy/cloudflare.md](../../docs/deploy/cloudflare.md)**. + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/index.ts` | Worker: boots Paperclip in the sandbox, proxies HTTP + WS | +| `src/lib.ts` | Pure helpers (unit-tested) | +| `container/Dockerfile` | Sandbox image: paperclipai + agent CLIs | +| `container/start-paperclip.sh` | Onboard-once boot script (non-root) | +| `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`. + +```sh +pnpm test # vitest: lib + config invariants (Dockerfile↔SDK version pin) +pnpm typecheck +``` diff --git a/deploy/cloudflare/container/Dockerfile b/deploy/cloudflare/container/Dockerfile new file mode 100644 index 0000000000..327dde04e4 --- /dev/null +++ b/deploy/cloudflare/container/Dockerfile @@ -0,0 +1,49 @@ +# Paperclip on Cloudflare Sandbox containers. +# +# The base image tag MUST match the @cloudflare/sandbox version pinned in +# ../package.json — the SDK and the in-container runtime are versioned +# together. test/config.test.ts enforces this. +FROM docker.io/cloudflare/sandbox:0.12.4 + +# Quality-of-life tools for agent workloads (subset of the tools the root +# Dockerfile's production stage installs; the sandbox base already ships +# git, curl, python3 and node). +RUN apt-get update \ + && apt-get install -y --no-install-recommends jq ripgrep openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Paperclip (npm release) plus the local-adapter agent CLIs, mirroring the +# root Dockerfile's production stage. +RUN npm install -g \ + paperclipai@latest \ + @anthropic-ai/claude-code@latest \ + @openai/codex@latest \ + opencode-ai \ + @google/gemini-cli@latest + +# Paperclip runtime defaults (mirrors the root Dockerfile / quadlet unit). +ENV HOST=0.0.0.0 \ + PORT=3100 \ + PAPERCLIP_HOME=/paperclip \ + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ + PAPERCLIP_DEPLOYMENT_EXPOSURE=private + +# Embedded Postgres refuses to run as root, so Paperclip gets its own user. +# The uid is pinned (must match PAPERCLIP_UID in src/lib.ts — enforced by +# test/config.test.ts) so the optional R2 attachments mount can be exposed +# as owned by this user. embedded-postgres also creates symlinks inside the +# package directory on first boot, hence the chown of the installed package. +RUN useradd -m -u 4100 -s /bin/bash paperclip \ + && mkdir -p /paperclip \ + && chown -R paperclip:paperclip /paperclip \ + && chown -R paperclip:paperclip /usr/local/lib/node_modules/paperclipai + +COPY start-paperclip.sh /opt/start-paperclip.sh +RUN chmod +x /opt/start-paperclip.sh + +# Local dev (wrangler dev) requires exposed ports to be declared. +EXPOSE 3100 + +# No USER directive on purpose (trivy DS-0002): the Cloudflare Sandbox +# runtime daemon in the base image must run as root; the Paperclip workload +# itself drops to the non-root `paperclip` user in start-paperclip.sh. diff --git a/deploy/cloudflare/container/start-paperclip.sh b/deploy/cloudflare/container/start-paperclip.sh new file mode 100644 index 0000000000..ddcc39b1aa --- /dev/null +++ b/deploy/cloudflare/container/start-paperclip.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Boots Paperclip as the non-root `paperclip` user (embedded Postgres refuses +# to run as root). Onboards non-interactively on first boot, then runs the +# server. Started by the Worker via sandbox.startProcess() with the runtime +# environment (PAPERCLIP_*, optional ANTHROPIC_API_KEY / DATABASE_URL) — +# runuser without --login preserves that environment for the child shell. +set -euo pipefail + +# Own the data dir, but skip the (optional) R2-mounted storage directory: +# s3fs rejects chown, which would abort the boot under `set -e`. The mount +# is already exposed with the paperclip uid via s3fs options (src/lib.ts). +find /paperclip -path /paperclip/instances/default/data/storage -prune \ + -o -exec chown paperclip:paperclip {} + + +exec runuser -u paperclip -- bash -c ' + set -euo pipefail + export HOME=/home/paperclip + if [ ! -f /paperclip/instances/default/config.json ]; then + paperclipai onboard --yes --bind lan --data-dir /paperclip + fi + exec paperclipai run --data-dir /paperclip +' diff --git a/deploy/cloudflare/package.json b/deploy/cloudflare/package.json new file mode 100644 index 0000000000..48b493baaf --- /dev/null +++ b/deploy/cloudflare/package.json @@ -0,0 +1,33 @@ +{ + "name": "@paperclipai/deploy-cloudflare", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Deploy Paperclip to Cloudflare Workers with a Sandbox container", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "deploy/cloudflare" + }, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cloudflare/sandbox": "0.12.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260701.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.3", + "vitest": "^4.1.10", + "wrangler": "^4.113.0" + } +} diff --git a/deploy/cloudflare/pnpm-lock.yaml b/deploy/cloudflare/pnpm-lock.yaml new file mode 100644 index 0000000000..516fd81055 --- /dev/null +++ b/deploy/cloudflare/pnpm-lock.yaml @@ -0,0 +1,1663 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@cloudflare/sandbox': + specifier: 0.12.4 + version: 0.12.4 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260701.0 + version: 4.20260702.1 + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + wrangler: + specifier: ^4.113.0 + version: 4.113.0(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@cloudflare/containers@0.3.7': + resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/sandbox@0.12.4': + resolution: {integrity: sha512-0gpkd+a58Q5aGjgXvLFZSdIDAZpvh89QlaiHBpTtkDZWqHPKfyISV76/pIUzzo9ACJzVIpUO8hNv2X+LFTTiRQ==} + peerDependencies: + '@openai/agents': ^0.3.3 + '@opencode-ai/sdk': ^1.1.40 + '@xterm/xterm': '>=5.0.0' + peerDependenciesMeta: + '@openai/agents': + optional: true + '@opencode-ai/sdk': + optional: true + '@xterm/xterm': + optional: true + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260721.1': + resolution: {integrity: sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + resolution: {integrity: sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260721.1': + resolution: {integrity: sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + resolution: {integrity: sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260721.1': + resolution: {integrity: sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@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==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + 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'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + capnweb@0.8.0: + resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + miniflare@4.20260721.0: + resolution: {integrity: sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==} + engines: {node: '>=22.0.0'} + hasBin: true + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + 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==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260721.1: + resolution: {integrity: sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.113.0: + resolution: {integrity: sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260721.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/containers@0.3.7': {} + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/sandbox@0.12.4': + dependencies: + '@cloudflare/containers': 0.3.7 + aws4fetch: 1.0.20 + capnweb: 0.8.0 + hono: 4.12.31 + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260721.1 + + '@cloudflare/workerd-darwin-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260721.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + 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 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@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': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + aws4fetch@1.0.20: {} + + blake3-wasm@2.1.5: {} + + capnweb@0.8.0: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + hono@4.12.31: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + 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 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@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: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.21 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260721.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260721.1 + '@cloudflare/workerd-darwin-arm64': 1.20260721.1 + '@cloudflare/workerd-linux-64': 1.20260721.1 + '@cloudflare/workerd-linux-arm64': 1.20260721.1 + '@cloudflare/workerd-windows-64': 1.20260721.1 + + wrangler@4.113.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260721.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260721.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/deploy/cloudflare/pnpm-workspace.yaml b/deploy/cloudflare/pnpm-workspace.yaml new file mode 100644 index 0000000000..df3ab17e7c --- /dev/null +++ b/deploy/cloudflare/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +# Marks deploy/cloudflare as its own single-package workspace so pnpm does +# 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. +allowBuilds: + esbuild: true + sharp: false + workerd: true diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts new file mode 100644 index 0000000000..5e0b9f54ca --- /dev/null +++ b/deploy/cloudflare/src/index.ts @@ -0,0 +1,130 @@ +/** + * Cloudflare Worker that serves a full Paperclip instance from a Cloudflare + * Sandbox container on the Worker's own origin (works on *.workers.dev — no + * custom domain required). + * + * Request flow: + * 1. Ensure the Paperclip boot process is running in the sandbox + * (idempotent; memoized per isolate, re-checked after any failure). + * 2. WebSocket upgrades -> sandbox.wsConnect(request, 3100) + * Everything else -> sandbox.containerFetch(request, 3100) + * 3. While the container provisions / Paperclip onboards, serve a + * self-refreshing 503 status page instead of a raw error. + * + * See docs/deploy/cloudflare.md for the operator guide. + */ +import { getSandbox, type Sandbox as SandboxType } from "@cloudflare/sandbox"; +import { + ARTIFACTS_BINDING, + PAPERCLIP_PORT, + SANDBOX_ID, + START_COMMAND, + STORAGE_MOUNT_PATH, + bootingResponse, + buildPaperclipEnv, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "./lib"; + +// Required by the Sandbox SDK: the Durable Object class backing the container, +// and the ContainerProxy entrypoint used for credential-less R2 bucket mounts +// (harmless when no bucket is configured). +export { ContainerProxy, Sandbox } from "@cloudflare/sandbox"; + +interface Env { + Sandbox: DurableObjectNamespace; + /** + * Optional R2 bucket for durable attachment storage — uncomment the + * r2_buckets block in wrangler.jsonc to enable. + */ + ARTIFACTS?: R2Bucket; + /** Optional override; defaults to the request origin (e.g. *.workers.dev). */ + PAPERCLIP_PUBLIC_URL?: string; + PAPERCLIP_DEPLOYMENT_MODE?: string; + PAPERCLIP_DEPLOYMENT_EXPOSURE?: string; + /** Secrets (wrangler secret put …); forwarded to the container when set. */ + ANTHROPIC_API_KEY?: string; + DATABASE_URL?: string; +} + +/** + * Per-isolate memo so steady-state requests skip the listProcesses round + * trip. Reset whenever proxying fails, which also heals container restarts + * (the boot process does not survive a sandbox sleep/wake cycle). + */ +let paperclipEnsured = false; + +async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): Promise { + const processes = await sandbox.listProcesses(); + if (isPaperclipRunning(processes)) return; + + // Mount durable attachment storage before Paperclip boots so the very + // first upload already lands in R2. Credential-less: the SDK routes s3fs + // traffic through the Worker's R2 binding (requires the ContainerProxy + // export above). + if (env[ARTIFACTS_BINDING]) { + try { + await sandbox.mountBucket(ARTIFACTS_BINDING, STORAGE_MOUNT_PATH, storageMountOptions()); + } catch (error) { + if (!isMountAlreadyInUse(error)) throw error; + } + } + + await sandbox.startProcess(START_COMMAND, { + env: buildPaperclipEnv({ + publicUrl: env.PAPERCLIP_PUBLIC_URL || `https://${requestUrl.host}`, + deploymentMode: env.PAPERCLIP_DEPLOYMENT_MODE, + deploymentExposure: env.PAPERCLIP_DEPLOYMENT_EXPOSURE, + anthropicApiKey: env.ANTHROPIC_API_KEY, + databaseUrl: env.DATABASE_URL, + }), + }); +} + +/** + * Distinguish the Sandbox SDK's "still starting" 5xx responses from genuine + * Paperclip errors so operators see the status page, not a JSON stack trace. + */ +async function isProvisioningResponse(response: Response): Promise { + if (response.status < 500) return false; + if (!response.headers.get("content-type")?.includes("json")) return false; + const text = await response.clone().text().catch(() => ""); + return isTransientBootMessage(text); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); + const url = new URL(request.url); + + try { + if (!paperclipEnsured) { + await ensurePaperclip(sandbox, env, url); + paperclipEnsured = true; + } + + if (isWebSocketUpgrade(request.headers)) { + return await sandbox.wsConnect(request, PAPERCLIP_PORT); + } + + const response = await sandbox.containerFetch(request, PAPERCLIP_PORT); + if (await isProvisioningResponse(response)) { + paperclipEnsured = false; + return bootingResponse(); + } + return response; + } catch (error) { + paperclipEnsured = false; + if (isTransientBootError(error)) { + return isWebSocketUpgrade(request.headers) + ? new Response("Paperclip is starting; retry shortly", { status: 503 }) + : bootingResponse(); + } + throw error; + } + }, +} satisfies ExportedHandler; diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts new file mode 100644 index 0000000000..64beff22f1 --- /dev/null +++ b/deploy/cloudflare/src/lib.ts @@ -0,0 +1,180 @@ +/** + * Pure helpers for the Cloudflare Sandbox deployment Worker. + * + * Everything here is side-effect free so it can be unit tested without a + * Workers runtime (see ../test/lib.test.ts). + */ + +/** Port the Paperclip server listens on inside the sandbox container. */ +export const PAPERCLIP_PORT = 3100; + +/** + * Stable sandbox id. One deployment == one Paperclip control plane, so a + * fixed id always routes to the same Durable Object / container. + */ +export const SANDBOX_ID = "paperclip"; + +/** Boot script baked into the container image (container/Dockerfile). */ +export const START_COMMAND = "/opt/start-paperclip.sh"; + +/** + * Optional R2 binding name for durable attachment storage. When the binding + * exists, the Worker FUSE-mounts the bucket (credential-less, via the SDK's + * egress interception) at Paperclip's local-disk storage directory before + * boot, so uploaded files survive container recycling. + */ +export const ARTIFACTS_BINDING = "ARTIFACTS"; + +/** + * Paperclip's local_disk storage provider path (docs/deploy/storage.md) + * under PAPERCLIP_HOME=/paperclip. Only file uploads live here — never the + * Postgres data directory, which must not sit on a FUSE mount. + */ +export const STORAGE_MOUNT_PATH = "/paperclip/instances/default/data/storage"; + +/** + * Fixed uid/gid of the non-root `paperclip` user created in + * container/Dockerfile (useradd -u). Pinned so the s3fs mount can be owned + * by that user; test/config.test.ts enforces the pin matches the Dockerfile. + */ +export const PAPERCLIP_UID = 4100; + +/** + * s3fs options for the attachments mount: expose it as owned by the + * `paperclip` user (s3fs mounts as root and rejects chown) and allow other + * users to traverse it. The SDK's R2 defaults are applied on top. + */ +export function storageMountOptions(): { s3fsOptions: string[] } { + return { + s3fsOptions: [ + "allow_other", + `uid=${PAPERCLIP_UID}`, + `gid=${PAPERCLIP_UID}`, + "umask=0022", + ], + }; +} + +/** Benign when two isolates race to mount the same path — first one wins. */ +export function isMountAlreadyInUse(error: unknown): boolean { + return error instanceof Error && /already in use/i.test(error.message); +} + +/** Process states that mean "no longer serving" (safe to start a new one). */ +const DEAD_STATUSES = new Set(["completed", "failed", "killed", "stopped"]); + +export interface ProcessLike { + command?: string; + status?: string; +} + +/** True when the request is a WebSocket upgrade that must not be buffered. */ +export function isWebSocketUpgrade(headers: Headers): boolean { + return headers.get("Upgrade")?.toLowerCase() === "websocket"; +} + +/** True when a live Paperclip boot process already exists in the sandbox. */ +export function isPaperclipRunning(processes: ProcessLike[]): boolean { + return processes.some( + (p) => (p.command ?? "").includes(START_COMMAND) && !DEAD_STATUSES.has(p.status ?? "") + ); +} + +export interface PaperclipEnvOptions { + /** Public origin the instance is reachable at, e.g. https://x.workers.dev */ + publicUrl: string; + deploymentMode?: string; + deploymentExposure?: string; + anthropicApiKey?: string; + databaseUrl?: string; +} + +/** + * Environment passed to the Paperclip boot process. Secrets are only + * forwarded when actually configured so the container env stays minimal. + */ +export function buildPaperclipEnv(options: PaperclipEnvOptions): Record { + const env: Record = { + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: options.deploymentMode ?? "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: options.deploymentExposure ?? "private", + PAPERCLIP_PUBLIC_URL: options.publicUrl, + }; + if (options.anthropicApiKey) env.ANTHROPIC_API_KEY = options.anthropicApiKey; + if (options.databaseUrl) env.DATABASE_URL = options.databaseUrl; + return env; +} + +/** + * Matches the Sandbox SDK's own transient startup errors: the container VM is + * still provisioning, or the port is not accepting connections yet (Paperclip + * onboards its database on first boot, which takes a minute or two). + * Deliberately specific to SDK wording so genuine Paperclip 5xx responses are + * never mistaken for boot noise. + */ +const TRANSIENT_BOOT_PATTERNS = [ + /currently provisioning/i, + /no container instance/i, + /container.*(?:not running|starting|is starting)/i, + /connection refused/i, + /port.*not (?:ready|mapped|found)/i, + /network connection lost/i, + /timed out.*(?:port|start|container)/i, +]; + +export function isTransientBootMessage(message: string): boolean { + return TRANSIENT_BOOT_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function isTransientBootError(error: unknown): boolean { + return error instanceof Error && isTransientBootMessage(error.message); +} + +/** + * Self-refreshing status page served while the container provisions and + * Paperclip onboards. Inline styles only — nothing else is reachable yet. + */ +export function bootingPage(): string { + return ` + + + + + +Paperclip is starting… + + + +
+
+

Paperclip is starting

+

The sandbox container is provisioning and Paperclip is onboarding its database.

+

First boot takes a minute or two. This page refreshes automatically.

+
+ +`; +} + +/** 503 + Retry-After so health checkers and browsers both behave. */ +export function bootingResponse(): Response { + return new Response(bootingPage(), { + status: 503, + headers: { + "content-type": "text/html; charset=utf-8", + "retry-after": "15", + "cache-control": "no-store", + }, + }); +} diff --git a/deploy/cloudflare/test/config.test.ts b/deploy/cloudflare/test/config.test.ts new file mode 100644 index 0000000000..2137348834 --- /dev/null +++ b/deploy/cloudflare/test/config.test.ts @@ -0,0 +1,90 @@ +/** + * Consistency checks across wrangler.jsonc, the container Dockerfile and + * package.json. These encode the deployment's cross-file invariants — most + * importantly the Sandbox SDK requirement that the base image tag match the + * @cloudflare/sandbox package version exactly. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PAPERCLIP_PORT, PAPERCLIP_UID, STORAGE_MOUNT_PATH } from "../src/lib"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function read(path: string): string { + return readFileSync(join(root, path), "utf8"); +} + +/** Minimal JSONC parser: strips // and /* *\/ comments outside strings. */ +function parseJsonc(text: string): any { + const stripped = text.replace( + /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, + (match) => (match.startsWith('"') ? match : "") + ); + return JSON.parse(stripped); +} + +const wrangler = parseJsonc(read("wrangler.jsonc")); +const pkg = JSON.parse(read("package.json")); +const dockerfile = read("container/Dockerfile"); + +describe("wrangler.jsonc", () => { + it("wires the Sandbox container, DO binding and migration to one class", () => { + const containerClass = wrangler.containers[0].class_name; + expect(containerClass).toBe("Sandbox"); + expect(wrangler.durable_objects.bindings[0].class_name).toBe(containerClass); + expect(wrangler.migrations[0].new_sqlite_classes).toContain(containerClass); + }); + + it("uses the container Dockerfile as the image", () => { + expect(wrangler.containers[0].image).toBe("./container/Dockerfile"); + }); + + it("enables nodejs_compat (required by the Sandbox SDK)", () => { + expect(wrangler.compatibility_flags).toContain("nodejs_compat"); + }); + + it("carries no account- or zone-specific configuration", () => { + const raw = read("wrangler.jsonc"); + expect(wrangler.account_id).toBeUndefined(); + expect(wrangler.routes).toBeUndefined(); + expect(raw).not.toMatch(/account_id/); + }); + + it("defaults to private exposure (embedded Postgres requirement)", () => { + expect(wrangler.vars.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private"); + }); +}); + +describe("container image", () => { + it("pins the base image to the exact @cloudflare/sandbox version", () => { + const sdkVersion = pkg.dependencies["@cloudflare/sandbox"]; + // Must be an exact pin — the SDK and in-container runtime version together. + expect(sdkVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(dockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sdkVersion}`); + }); + + it("exposes the Paperclip port for local dev", () => { + 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("pins the paperclip uid the R2 mount options rely on", () => { + expect(dockerfile).toContain(`useradd -m -u ${PAPERCLIP_UID} `); + }); +}); + +describe("boot script", () => { + const script = read("container/start-paperclip.sh"); + + it("excludes the R2 storage mount from the ownership pass", () => { + // s3fs rejects chown; a bare `chown -R /paperclip` would abort the boot. + expect(script).toContain(`-path ${STORAGE_MOUNT_PATH} -prune`); + expect(script).not.toMatch(/chown -R paperclip:paperclip \/paperclip\s*$/m); + }); +}); diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts new file mode 100644 index 0000000000..3db3b41b1f --- /dev/null +++ b/deploy/cloudflare/test/lib.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + PAPERCLIP_PORT, + PAPERCLIP_UID, + START_COMMAND, + STORAGE_MOUNT_PATH, + bootingPage, + bootingResponse, + buildPaperclipEnv, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "../src/lib"; + +describe("isWebSocketUpgrade", () => { + it("detects a standard upgrade request", () => { + const headers = new Headers({ Upgrade: "websocket", Connection: "Upgrade" }); + expect(isWebSocketUpgrade(headers)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isWebSocketUpgrade(new Headers({ Upgrade: "WebSocket" }))).toBe(true); + }); + + it("rejects plain requests and non-websocket upgrades", () => { + expect(isWebSocketUpgrade(new Headers())).toBe(false); + expect(isWebSocketUpgrade(new Headers({ Upgrade: "h2c" }))).toBe(false); + }); +}); + +describe("isPaperclipRunning", () => { + it("finds a live boot process", () => { + expect( + isPaperclipRunning([{ command: START_COMMAND, status: "running" }]) + ).toBe(true); + expect( + isPaperclipRunning([{ command: `bash ${START_COMMAND}`, status: "starting" }]) + ).toBe(true); + }); + + it("ignores dead processes so a restart can happen", () => { + for (const status of ["completed", "failed", "killed", "stopped"]) { + expect(isPaperclipRunning([{ command: START_COMMAND, status }])).toBe(false); + } + }); + + it("ignores unrelated processes and empty lists", () => { + expect(isPaperclipRunning([])).toBe(false); + expect(isPaperclipRunning([{ command: "sleep 1", status: "running" }])).toBe(false); + expect(isPaperclipRunning([{}])).toBe(false); + }); +}); + +describe("buildPaperclipEnv", () => { + it("produces the baseline environment", () => { + const env = buildPaperclipEnv({ publicUrl: "https://example.workers.dev" }); + expect(env).toEqual({ + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", + PAPERCLIP_PUBLIC_URL: "https://example.workers.dev", + }); + }); + + it("only forwards secrets that are actually set", () => { + const bare = buildPaperclipEnv({ publicUrl: "https://x.dev" }); + expect(bare).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(bare).not.toHaveProperty("DATABASE_URL"); + + const withSecrets = buildPaperclipEnv({ + publicUrl: "https://x.dev", + anthropicApiKey: "test-key", + databaseUrl: "postgres://example", + }); + expect(withSecrets.ANTHROPIC_API_KEY).toBe("test-key"); + expect(withSecrets.DATABASE_URL).toBe("postgres://example"); + }); + + it("honors mode/exposure overrides", () => { + const env = buildPaperclipEnv({ + publicUrl: "https://x.dev", + deploymentMode: "authenticated", + deploymentExposure: "public", + }); + expect(env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("public"); + }); +}); + +describe("transient boot detection", () => { + it("matches the Sandbox SDK's startup errors", () => { + for (const message of [ + "Container is currently provisioning. This can take several minutes on first deployment.", + "no container instance available", + "connection refused", + "Port 3100 not ready", + "Network connection lost", + ]) { + expect(isTransientBootMessage(message), message).toBe(true); + expect(isTransientBootError(new Error(message)), message).toBe(true); + } + }); + + it("does not swallow genuine application errors", () => { + for (const message of [ + "Internal Server Error", + "database migration failed", + "TypeError: cannot read properties of undefined", + ]) { + expect(isTransientBootMessage(message), message).toBe(false); + } + expect(isTransientBootError("connection refused")).toBe(false); // non-Error + }); +}); + +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"); + expect(STORAGE_MOUNT_PATH).not.toContain("postgres"); + }); + + it("exposes the mount as the paperclip user", () => { + const { s3fsOptions } = storageMountOptions(); + expect(s3fsOptions).toContain(`uid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain(`gid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain("allow_other"); + }); + + it("recognizes the benign already-mounted race", () => { + expect(isMountAlreadyInUse(new Error("Mount path already in use: /x"))).toBe(true); + expect(isMountAlreadyInUse(new Error("S3FS mount command failed"))).toBe(false); + expect(isMountAlreadyInUse("already in use")).toBe(false); // non-Error + }); +}); + +describe("booting page", () => { + it("self-refreshes and explains what is happening", () => { + const html = bootingPage(); + expect(html).toContain('http-equiv="refresh"'); + expect(html).toContain("Paperclip is starting"); + }); + + it("responds 503 with Retry-After and no caching", () => { + const response = bootingResponse(); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("15"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain("text/html"); + }); +}); diff --git a/deploy/cloudflare/tsconfig.json b/deploy/cloudflare/tsconfig.json new file mode 100644 index 0000000000..27c4518429 --- /dev/null +++ b/deploy/cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/deploy/cloudflare/vitest.config.ts b/deploy/cloudflare/vitest.config.ts new file mode 100644 index 0000000000..ed8bf7739b --- /dev/null +++ b/deploy/cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc new file mode 100644 index 0000000000..638714bc72 --- /dev/null +++ b/deploy/cloudflare/wrangler.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "paperclip-sandbox", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "compatibility_flags": ["nodejs_compat"], + "containers": [ + { + "class_name": "Sandbox", + "image": "./container/Dockerfile", + // Paperclip control plane + embedded Postgres + agent CLI processes. + // Shrink at your own risk; embedded Postgres alone wants real memory. + "instance_type": { "vcpu": 2, "memory_mib": 8192, "disk_mb": 10240 }, + // One control plane per deployment — a fixed sandbox id routes every + // request to the same instance, so extra instances would sit idle. + "max_instances": 1, + "name": "paperclip-sandbox" + } + ], + "durable_objects": { + "bindings": [{ "class_name": "Sandbox", "name": "Sandbox" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Sandbox"] }], + // Optional: durable attachment storage. Paperclip's upload directory is + // FUSE-mounted from this bucket (credential-less, via the Worker binding), + // so attachments survive container recycling. To enable: + // 1. npx wrangler r2 bucket create paperclip-attachments + // 2. Uncomment the block below and redeploy. + // See docs/deploy/cloudflare.md#durable-attachments-via-r2-optional. + // "r2_buckets": [ + // { "binding": "ARTIFACTS", "bucket_name": "paperclip-attachments" } + // ], + "vars": { + // Defaults follow docs/deploy/deployment-modes.md. Embedded Postgres + // currently requires "private" exposure; switch to "public" only with an + // external DATABASE_URL (see docs/deploy/cloudflare.md#data-durability). + "PAPERCLIP_DEPLOYMENT_MODE": "authenticated", + "PAPERCLIP_DEPLOYMENT_EXPOSURE": "private", + // Leave empty to default to the request origin (your workers.dev URL). + "PAPERCLIP_PUBLIC_URL": "" + }, + "observability": { "enabled": true } +} diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md new file mode 100644 index 0000000000..7b2b8f6fd0 --- /dev/null +++ b/docs/deploy/cloudflare.md @@ -0,0 +1,144 @@ +--- +title: Cloudflare +summary: Run Paperclip on Cloudflare Workers with a Sandbox container +--- + +Deploy a full Paperclip instance to Cloudflare: a Worker proxies your +`*.workers.dev` origin into a [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) +container that runs `paperclipai` (npm release) with embedded Postgres and the +local-adapter agent CLIs (Claude Code, Codex, OpenCode, Gemini) preinstalled. + +No custom domain is required — HTTP and WebSocket traffic are proxied on the +Worker's own origin. + +## ⚠️ Data Durability + +**Sandbox container disk is ephemeral.** When the container sleeps after +inactivity or is replaced by a deploy, the embedded Postgres data directory is +wiped — companies, agents, issues, everything. + +Treat the default configuration as an **evaluation deployment**. For anything +you want to keep, point Paperclip at an external Postgres before onboarding: + +```sh +npx wrangler secret put DATABASE_URL +# e.g. postgres://USER:PASSWORD@HOST:5432/paperclip (Neon, Supabase, RDS, …) +``` + +The Worker forwards `DATABASE_URL` into the container and Paperclip uses it +instead of embedded Postgres. + +## Durable Attachments via R2 (Optional) + +Uploaded files (issue attachments, images) can survive container recycling +without any Paperclip configuration: the Worker FUSE-mounts an R2 bucket at +Paperclip's [local-disk storage directory](/deploy/storage) before boot, +credential-less, through the Worker's own R2 binding. + +```sh +npx wrangler r2 bucket create paperclip-attachments +# then uncomment the r2_buckets block in wrangler.jsonc and redeploy +``` + +Notes: + +- Only the attachments directory is mounted. The Postgres data directory + stays on container disk **by design** — databases must not run on FUSE + mounts. Durable attachments complement, not replace, `DATABASE_URL`. +- In `wrangler dev` the SDK syncs the directory through the R2 binding + instead of s3fs; behavior is equivalent for testing. +- Alternatively, Paperclip's own `s3` storage provider can talk to R2 + directly via its S3-compatible API (`paperclipai configure --section + storage`) — the mount is just the zero-config path. + +## Prerequisites + +- A Cloudflare account on the **Workers Paid** plan (containers are not + 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 + +## Deploy + +```sh +cd deploy/cloudflare +pnpm install --ignore-workspace +npx wrangler login +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 +(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. + +## Configuration + +Set via `vars` in `wrangler.jsonc` or `wrangler secret put`: + +| Name | Kind | Default | Purpose | +| --- | --- | --- | --- | +| `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` | +| `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) | + +Container sizing lives in `wrangler.jsonc` (`instance_type`, default +2 vCPU / 8 GiB / 10 GB). Embedded Postgres plus concurrent agent processes +want real memory; shrink with care. + +## How It Works + +- `src/index.ts` — Worker entry. On each request it idempotently ensures the + Paperclip boot process is running (`sandbox.startProcess`), then proxies: + WebSocket upgrades via `sandbox.wsConnect(request, 3100)`, everything else + via `sandbox.containerFetch(request, 3100)`. Same-origin proxying keeps + Paperclip's cookies and live-update WebSockets on one host. +- `container/Dockerfile` — extends `cloudflare/sandbox` (tag pinned to the + `@cloudflare/sandbox` package version; enforced by `test/config.test.ts`), + installs `paperclipai` and the agent CLIs. +- `container/start-paperclip.sh` — onboards once + (`paperclipai onboard --yes --bind lan`), then `paperclipai run`, as a + non-root user (embedded Postgres refuses root). + +## Local Development + +```sh +cd deploy/cloudflare +pnpm install --ignore-workspace +pnpm dev # wrangler dev — builds and runs the container via Docker +``` + +Run the unit and config-consistency tests: + +```sh +pnpm test +pnpm typecheck +``` + +## Troubleshooting + +- **Status page loops for more than ~5 minutes** — check `npx wrangler tail` + for container/start errors. First-ever deploys can also spend a few minutes + provisioning container capacity. +- **`Container is currently provisioning`** in logs is normal on first boot. +- **Everything reset after idling** — that's the ephemerality caveat above; + configure `DATABASE_URL`. +- **Inspect the container directly** — `npx wrangler dev` locally, then + `docker exec` into the running container; or add temporary debug output to + `start-paperclip.sh`. + +## Cost Notes + +You pay for Worker requests plus container runtime (vCPU-seconds, memory, +disk) while the sandbox is awake. The container sleeps after inactivity; +with embedded Postgres that also means data loss (see above), which is the +other reason to use an external database. diff --git a/docs/docs.json b/docs/docs.json index 5787bda055..5c8fb9e543 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -82,6 +82,7 @@ "deploy/local-development", "deploy/tailscale-private-access", "deploy/docker", + "deploy/cloudflare", "deploy/deployment-modes", "deploy/database", "deploy/secrets", From 26687784b7dd9deb359a4e893c141246795a6d4f Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:23:24 -0500 Subject: [PATCH 2/5] docs(deploy): mark R2 attachments mount experimental; sync anchor Claude-Session: https://claude.ai/code/session_01NWpwXBqxrPZdv75ud8xszr --- deploy/cloudflare/wrangler.jsonc | 8 ++++---- docs/deploy/cloudflare.md | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index 638714bc72..fb86712dca 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -21,12 +21,12 @@ "bindings": [{ "class_name": "Sandbox", "name": "Sandbox" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Sandbox"] }], - // Optional: durable attachment storage. Paperclip's upload directory is - // FUSE-mounted from this bucket (credential-less, via the Worker binding), - // so attachments survive container recycling. To enable: + // Optional (experimental): durable attachment storage. Paperclip's upload + // directory is FUSE-mounted from this bucket (credential-less, via the + // Worker binding), so attachments survive container recycling. To enable: // 1. npx wrangler r2 bucket create paperclip-attachments // 2. Uncomment the block below and redeploy. - // See docs/deploy/cloudflare.md#durable-attachments-via-r2-optional. + // See docs/deploy/cloudflare.md#durable-attachments-via-r2-optional-experimental. // "r2_buckets": [ // { "binding": "ARTIFACTS", "bucket_name": "paperclip-attachments" } // ], diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md index 7b2b8f6fd0..3186eb814b 100644 --- a/docs/deploy/cloudflare.md +++ b/docs/deploy/cloudflare.md @@ -28,7 +28,11 @@ npx wrangler secret put DATABASE_URL The Worker forwards `DATABASE_URL` into the container and Paperclip uses it instead of embedded Postgres. -## Durable Attachments via R2 (Optional) +## Durable Attachments via R2 (Optional, Experimental) + +> **Experimental:** this mount path is newer than the rest of the deployment +> and has not yet been validated on a live deployment. The default (no R2 +> binding) is unaffected. Uploaded files (issue attachments, images) can survive container recycling without any Paperclip configuration: the Worker FUSE-mounts an R2 bucket at 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 3/5] 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 ``` From 86eb6d23a671f88bcd4a9cbcf18bb40710c7bce5 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:11:06 -0500 Subject: [PATCH 4/5] fix(deploy): make bootstrap gate fail-closed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 follow-up: an unset or empty BOOTSTRAP_TOKEN no longer leaves the unclaimed operator invite exposed — the Worker serves only a setup page until the secret exists. Post-claim, operators open the deployment explicitly with DISABLE_BOOTSTRAP_GATE="true". Claude-Session: https://claude.ai/code/session_01NWpwXBqxrPZdv75ud8xszr --- deploy/cloudflare/README.md | 2 +- deploy/cloudflare/src/index.ts | 38 +++++++++++++++++------ deploy/cloudflare/src/lib.ts | 50 ++++++++++++++++++++++++++++++ deploy/cloudflare/test/lib.test.ts | 30 ++++++++++++++++++ deploy/cloudflare/wrangler.jsonc | 7 ++++- docs/deploy/cloudflare.md | 30 ++++++++---------- 6 files changed, 129 insertions(+), 28 deletions(-) diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index 7da32ffb29..cd7245c0ea 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -9,8 +9,8 @@ preinstalled. No custom domain required. ```sh pnpm install npx wrangler login -npx wrangler secret put BOOTSTRAP_TOKEN # gate access until you claim the operator account npx wrangler deploy +npx wrangler secret put BOOTSTRAP_TOKEN # required — deployment is fail-closed until set ``` > ⚠️ Container disk is **ephemeral** — for any data you want to keep, set an diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index a753ab6762..0f0bf1df0d 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -24,8 +24,10 @@ import { STORAGE_MOUNT_PATH, accessDeniedPage, bootingResponse, + bootstrapGateMode, buildPaperclipEnv, getCookie, + setupRequiredPage, isMountAlreadyInUse, isPaperclipRunning, isTransientBootError, @@ -54,12 +56,18 @@ interface Env { 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. + * Required before the deployment serves anything (fail-closed): 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. */ BOOTSTRAP_TOKEN?: string; + /** + * Set to "true" (wrangler.jsonc vars) after the operator account is + * claimed to open the login page to your team; Paperclip's own auth + * protects everything from then on. + */ + DISABLE_BOOTSTRAP_GATE?: string; } /** @@ -92,15 +100,27 @@ async function tokensMatch(presented: string, expected: string): Promise { - if (!env.BOOTSTRAP_TOKEN) return null; + const mode = bootstrapGateMode({ + token: env.BOOTSTRAP_TOKEN, + disableGate: env.DISABLE_BOOTSTRAP_GATE, + }); + if (mode === "open") return null; + if (mode === "setup") { + return new Response(setupRequiredPage(), { + status: 403, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }); + } + // mode === "token": BOOTSTRAP_TOKEN is guaranteed non-empty here. const presented = url.searchParams.get(BOOTSTRAP_PARAM); - if (presented !== null && (await tokensMatch(presented, env.BOOTSTRAP_TOKEN))) { + 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, { @@ -108,14 +128,14 @@ async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promi headers: { location: url.toString(), "set-cookie": - `${BOOTSTRAP_COOKIE}=${encodeURIComponent(env.BOOTSTRAP_TOKEN)}; ` + + `${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))) { + if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN!))) { return null; } diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts index 3b578e2a9a..d9eb573948 100644 --- a/deploy/cloudflare/src/lib.ts +++ b/deploy/cloudflare/src/lib.ts @@ -90,6 +90,56 @@ export function getCookie(cookieHeader: string | null, name: string): string | u return undefined; } +/** + * Access-gate decision, fail-closed by default: + * - "open" — operator explicitly disabled the gate (post-claim state) + * - "setup" — no usable token configured; serve the setup page, proxy nothing + * - "token" — token configured; require it (query param once, cookie after) + * An empty-string token counts as unconfigured — it must never open the gate. + */ +export function bootstrapGateMode(options: { + token?: string; + disableGate?: string; +}): "open" | "setup" | "token" { + if (options.disableGate === "true") return "open"; + if (!options.token) return "setup"; + return "token"; +} + +/** 403 page served fail-closed until BOOTSTRAP_TOKEN is configured. */ +export function setupRequiredPage(): string { + return ` + + + + +Paperclip — setup required + + + +
+

Setup required

+

This Paperclip deployment starts locked so that nobody + else can claim the operator account before you do.

+

Set a bootstrap token, then open this URL with + ?${BOOTSTRAP_PARAM}=<your token>:

+

npx wrangler secret put BOOTSTRAP_TOKEN

+

After you claim the operator account you can open the deployment to your + team by setting DISABLE_BOOTSTRAP_GATE to "true" + in wrangler.jsonc and redeploying.

+
+ +`; +} + /** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */ export function accessDeniedPage(): string { return ` diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts index ae2cab17ec..ad4d4b1246 100644 --- a/deploy/cloudflare/test/lib.test.ts +++ b/deploy/cloudflare/test/lib.test.ts @@ -6,7 +6,9 @@ import { START_COMMAND, STORAGE_MOUNT_PATH, accessDeniedPage, + bootstrapGateMode, getCookie, + setupRequiredPage, bootingPage, bootingResponse, buildPaperclipEnv, @@ -120,6 +122,34 @@ describe("transient boot detection", () => { }); }); +describe("bootstrapGateMode", () => { + it("fails closed when no token is configured", () => { + expect(bootstrapGateMode({})).toBe("setup"); + expect(bootstrapGateMode({ token: undefined })).toBe("setup"); + }); + + it("treats an empty-string token as unconfigured, never open", () => { + expect(bootstrapGateMode({ token: "" })).toBe("setup"); + }); + + it("requires the token when one is configured", () => { + expect(bootstrapGateMode({ token: "s3cret" })).toBe("token"); + }); + + it("only opens on the explicit literal opt-out", () => { + expect(bootstrapGateMode({ disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ token: "s3cret", disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ disableGate: "TRUE" })).toBe("setup"); + expect(bootstrapGateMode({ disableGate: "1" })).toBe("setup"); + }); + + it("setup page tells the operator how to configure the gate", () => { + const html = setupRequiredPage(); + expect(html).toContain("BOOTSTRAP_TOKEN"); + expect(html).toContain("DISABLE_BOOTSTRAP_GATE"); + }); +}); + describe("bootstrap gate helpers", () => { it("extracts a single cookie value", () => { expect(getCookie("a=1; paperclip_bootstrap=tok; b=2", "paperclip_bootstrap")).toBe("tok"); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index fb86712dca..9b5fc7ded0 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -37,7 +37,12 @@ "PAPERCLIP_DEPLOYMENT_MODE": "authenticated", "PAPERCLIP_DEPLOYMENT_EXPOSURE": "private", // Leave empty to default to the request origin (your workers.dev URL). - "PAPERCLIP_PUBLIC_URL": "" + "PAPERCLIP_PUBLIC_URL": "", + // The deployment starts fail-closed: it serves nothing until you set the + // BOOTSTRAP_TOKEN secret (protects the unclaimed operator invite). After + // claiming the operator account, flip this to "true" to open the login + // page to your team — Paperclip's own auth takes over from there. + "DISABLE_BOOTSTRAP_GATE": "false" }, "observability": { "enabled": true } } diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md index 68fb197a74..c871b24e47 100644 --- a/docs/deploy/cloudflare.md +++ b/docs/deploy/cloudflare.md @@ -69,13 +69,12 @@ Notes: cd deploy/cloudflare 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 +# required before anything is served: 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 + # optional: give in-container agents an API key npx wrangler secret put ANTHROPIC_API_KEY ``` @@ -88,18 +87,14 @@ 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: +deployment is therefore **fail-closed**: until `BOOTSTRAP_TOKEN` is set, the +Worker serves only a setup page, and with the token set, requests must +present it (query param once; cookie afterwards) or receive a 401 — nothing +reaches Paperclip either way. -```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. +After you claim the operator account, open the deployment to your team by +setting `DISABLE_BOOTSTRAP_GATE` to `"true"` in `wrangler.jsonc` and +redeploying — from that point Paperclip's own login protects everything. ## Configuration @@ -110,7 +105,8 @@ 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) | +| `BOOTSTRAP_TOKEN` | secret | — (required) | Fail-closed gate: nothing is served until set (see Deploy) | +| `DISABLE_BOOTSTRAP_GATE` | var | `"false"` | Set `"true"` after claiming the operator account to open team logins | | `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) | From 7b8d152949136d66dc74cf75b06e4c3af138b7f0 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:49:34 -0500 Subject: [PATCH 5/5] Harden the Cloudflare deployment Worker Fixes a crash and closes three gaps in the request path. The bootstrap gate called decodeURIComponent directly on the cookie, which is entirely client-controlled, and the gate runs above the fetch handler's try/catch. A malformed escape such as "%ZZ" therefore threw an unhandled URIError, so one bad cookie produced a hard Worker exception on every subsequent request from that client until they cleared it. getCookie returns the raw value by design, so nothing upstream sanitised it. decodeCookieValue now treats an undecodable cookie as absent, with a regression test. A public endpoint fronting a paid model is supposed to ship size caps and an origin allowlist from the start: - exceedsRequestSizeLimit rejects declared bodies over 100MB with a 413. Only Content-Length is inspected; buffering a chunked body to measure it would be the exhaustion the check exists to prevent. - isOriginAllowed answers 403 for cross-origin requests, same-origin by default and extensible through ALLOWED_ORIGINS. Requests with no Origin header pass, because the CLI, agents and health checks never send one. Rate limiting is deliberately not included: the GA ratelimit binding needs a namespace_id and a limit/period policy, which are deployment decisions. Also bumps compatibility_date to the current date rather than an inherited one, and replaces the max-width page shells in the status pages with a fluid width:min(100%,60ch). --- deploy/cloudflare/src/index.ts | 21 +++++++++- deploy/cloudflare/src/lib.ts | 65 +++++++++++++++++++++++++++++- deploy/cloudflare/test/lib.test.ts | 37 +++++++++++++++++ deploy/cloudflare/wrangler.jsonc | 2 +- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index 0f0bf1df0d..02d40abcf3 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -26,7 +26,10 @@ import { bootingResponse, bootstrapGateMode, buildPaperclipEnv, + decodeCookieValue, + exceedsRequestSizeLimit, getCookie, + isOriginAllowed, setupRequiredPage, isMountAlreadyInUse, isPaperclipRunning, @@ -68,6 +71,12 @@ interface Env { * protects everything from then on. */ DISABLE_BOOTSTRAP_GATE?: string; + /** + * Comma-separated extra origins permitted to send cross-origin requests. + * Unset means same-origin only. Requests with no Origin header (CLI, agents, + * health checks) are always allowed — see isOriginAllowed. + */ + ALLOWED_ORIGINS?: string; } /** @@ -135,7 +144,8 @@ async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promi } const cookie = getCookie(request.headers.get("Cookie"), BOOTSTRAP_COOKIE); - if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN!))) { + const decoded = cookie === undefined ? undefined : decodeCookieValue(cookie); + if (decoded !== undefined && (await tokensMatch(decoded, env.BOOTSTRAP_TOKEN!))) { return null; } @@ -190,6 +200,15 @@ export default { const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); const url = new URL(request.url); + // Cheap rejections first, before the gate does any crypto or the sandbox + // is touched at all. + if (exceedsRequestSizeLimit(request.headers)) { + return new Response("Request body too large", { status: 413 }); + } + if (!isOriginAllowed(request.headers.get("Origin"), url.origin, env.ALLOWED_ORIGINS)) { + return new Response("Origin not allowed", { status: 403 }); + } + const denied = await enforceBootstrapGate(request, env, url); if (denied) return denied; diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts index d9eb573948..36f97be268 100644 --- a/deploy/cloudflare/src/lib.ts +++ b/deploy/cloudflare/src/lib.ts @@ -90,6 +90,67 @@ export function getCookie(cookieHeader: string | null, name: string): string | u return undefined; } +/** + * decodeURIComponent throws URIError on a malformed escape ("%ZZ"), and the + * cookie is fully client-controlled. Raw `decodeURIComponent` in the gate meant + * one bad cookie produced an unhandled Worker exception on every request from + * that client until they cleared it. A cookie that cannot be decoded simply is + * not a valid token, so treat it as absent. + */ +export function decodeCookieValue(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +/** + * Largest request body proxied into the sandbox, in bytes. Attachments go + * through this Worker, so the cap is generous — it exists to stop a single + * request exhausting container memory, not to police normal uploads. + */ +export const MAX_REQUEST_BYTES = 100 * 1024 * 1024; + +/** + * Reject an oversized body before it reaches the container. Only Content-Length + * is checked: a chunked upload without one is passed through, because buffering + * it here to measure it would itself be the resource exhaustion this prevents. + */ +export function exceedsRequestSizeLimit( + headers: Headers, + limit: number = MAX_REQUEST_BYTES, +): boolean { + const raw = headers.get("Content-Length"); + if (raw === null) return false; + const length = Number(raw); + return Number.isFinite(length) && length > limit; +} + +/** + * Origin allowlist for state-changing cross-origin requests. Empty or unset + * means same-origin-only, which is the safe default; the deployment is a single + * origin, so no browser client legitimately posts from anywhere else. + * + * Requests with no Origin header are allowed: non-browser clients (the CLI, + * agents, health checks) never send one, and the bootstrap gate plus + * Paperclip's own auth are what actually authenticate them. + */ +export function isOriginAllowed( + origin: string | null, + selfOrigin: string, + allowlist?: string, +): boolean { + if (origin === null) return true; + if (origin === selfOrigin) return true; + if (!allowlist) return false; + return allowlist + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .includes(origin); +} + /** * Access-gate decision, fail-closed by default: * - "open" — operator explicitly disabled the gate (post-claim state) @@ -118,7 +179,7 @@ export function setupRequiredPage(): string { :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:36rem;padding:2rem} + main{width:min(100%,60ch);padding:clamp(1rem,4vw,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} @@ -152,7 +213,7 @@ export function accessDeniedPage(): string { :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} + main{width:min(100%,60ch);padding:clamp(1rem,4vw,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} diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts index ad4d4b1246..597bd2a5bf 100644 --- a/deploy/cloudflare/test/lib.test.ts +++ b/deploy/cloudflare/test/lib.test.ts @@ -6,8 +6,12 @@ import { START_COMMAND, STORAGE_MOUNT_PATH, accessDeniedPage, + MAX_REQUEST_BYTES, bootstrapGateMode, + decodeCookieValue, + exceedsRequestSizeLimit, getCookie, + isOriginAllowed, setupRequiredPage, bootingPage, bootingResponse, @@ -162,6 +166,39 @@ describe("bootstrap gate helpers", () => { expect(getCookie("xpaperclip_bootstrap=evil", "paperclip_bootstrap")).toBeUndefined(); }); + it("treats an undecodable cookie as absent rather than throwing", () => { + // decodeURIComponent("%ZZ") throws URIError, and the gate runs before the + // Worker's try/catch — so a raw decode turned one malformed client cookie + // into an unhandled exception on every subsequent request from that client. + expect(() => decodeCookieValue("%ZZ")).not.toThrow(); + expect(decodeCookieValue("%ZZ")).toBeUndefined(); + expect(decodeCookieValue("%E0%A4%A")).toBeUndefined(); + expect(decodeCookieValue("plain")).toBe("plain"); + expect(decodeCookieValue("a%20b")).toBe("a b"); + }); + + it("rejects only bodies declaring more than the cap", () => { + const h = (v?: string) => new Headers(v === undefined ? {} : { "Content-Length": v }); + expect(exceedsRequestSizeLimit(h())).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES)))).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES + 1)))).toBe(true); + // A non-numeric Content-Length is not evidence of an oversized body. + expect(exceedsRequestSizeLimit(h("not-a-number"))).toBe(false); + }); + + it("allows same-origin and header-less callers, blocks other origins", () => { + const self = "https://paperclip.example.workers.dev"; + // CLI, agents and health checks send no Origin at all. + expect(isOriginAllowed(null, self)).toBe(true); + expect(isOriginAllowed(self, self)).toBe(true); + expect(isOriginAllowed("https://evil.example", self)).toBe(false); + expect(isOriginAllowed("https://ok.example", self, "https://ok.example")).toBe(true); + expect(isOriginAllowed("https://ok.example", self, " https://a.test , https://ok.example ")).toBe( + true, + ); + expect(isOriginAllowed("https://evil.example", self, "https://ok.example")).toBe(false); + }); + it("access-denied page names the param and secret", () => { const html = accessDeniedPage(); expect(html).toContain(BOOTSTRAP_PARAM); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index 9b5fc7ded0..309a523553 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "paperclip-sandbox", "main": "src/index.ts", - "compatibility_date": "2026-07-01", + "compatibility_date": "2026-08-29", "compatibility_flags": ["nodejs_compat"], "containers": [ {