This commit is contained in:
Daniel Bodnar 2026-09-13 20:50:17 +09:00 committed by GitHub
commit 1962a31a60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2696 additions and 0 deletions

5
deploy/cloudflare/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,43 @@
# 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
npx wrangler login
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
> 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 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)
pnpm typecheck
```

View File

@ -0,0 +1,51 @@
# 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. 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@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 \
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.

View File

@ -0,0 +1,25 @@
#!/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 {} +
# 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
paperclipai onboard --yes --bind lan --data-dir /paperclip
fi
exec paperclipai run --data-dir /paperclip
'

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
# 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.
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
workerd: true

View File

@ -0,0 +1,244 @@
/**
* 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,
BOOTSTRAP_COOKIE,
BOOTSTRAP_PARAM,
PAPERCLIP_PORT,
SANDBOX_ID,
START_COMMAND,
STORAGE_MOUNT_PATH,
accessDeniedPage,
bootingResponse,
bootstrapGateMode,
buildPaperclipEnv,
decodeCookieValue,
exceedsRequestSizeLimit,
getCookie,
isOriginAllowed,
setupRequiredPage,
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<SandboxType>;
/**
* 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;
/**
* 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;
/**
* 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;
}
/**
* 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;
/**
* Shared in-flight boot so concurrent cold-start requests in one isolate
* issue a single ensure pass instead of racing startProcess. Cross-isolate
* duplicates are additionally serialized by the flock in
* container/start-paperclip.sh duplicates exit immediately.
*/
let ensureInFlight: Promise<void> | null = null;
/** Constant-time comparison via digest so token checks don't leak timing. */
async function tokensMatch(presented: string, expected: string): Promise<boolean> {
const encoder = new TextEncoder();
const [a, b] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(presented)),
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
]);
const av = new Uint8Array(a);
const bv = new Uint8Array(b);
let diff = 0;
for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i];
return diff === 0;
}
/**
* Bootstrap gate, fail-closed: with no token configured the deployment
* serves only the setup page; with a token, only requests presenting it
* (query param once, cookie afterwards) reach Paperclip. Returns null when
* the request may proceed, otherwise the response to serve.
*/
async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promise<Response | null> {
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!))) {
// 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);
const decoded = cookie === undefined ? undefined : decodeCookieValue(cookie);
if (decoded !== undefined && (await tokensMatch(decoded, env.BOOTSTRAP_TOKEN!))) {
return null;
}
return new Response(accessDeniedPage(), {
status: 401,
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
});
}
async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): Promise<void> {
const processes = await sandbox.listProcesses();
if (isPaperclipRunning(processes)) return;
// 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({
// 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,
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<boolean> {
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<Response> {
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;
try {
if (!paperclipEnsured) {
ensureInFlight ??= ensurePaperclip(sandbox, env, url).finally(() => {
ensureInFlight = null;
});
await ensureInFlight;
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<Env>;

View File

@ -0,0 +1,339 @@
/**
* 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";
}
/** 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;
}
/**
* 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)
* - "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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Paperclip setup required</title>
<style>
:root{color-scheme:light dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,sans-serif;background:#0d1017;color:#e6e6e6}
main{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}
</style>
</head>
<body>
<main>
<h1>Setup required</h1>
<p>This Paperclip deployment starts <strong>locked</strong> so that nobody
else can claim the operator account before you do.</p>
<p>Set a bootstrap token, then open this URL with
<code>?${BOOTSTRAP_PARAM}=&lt;your token&gt;</code>:</p>
<p><code>npx wrangler secret put BOOTSTRAP_TOKEN</code></p>
<p>After you claim the operator account you can open the deployment to your
team by setting <code>DISABLE_BOOTSTRAP_GATE</code> to <code>"true"</code>
in <code>wrangler.jsonc</code> and redeploying.</p>
</main>
</body>
</html>`;
}
/** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */
export function accessDeniedPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Paperclip access restricted</title>
<style>
:root{color-scheme:light dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,sans-serif;background:#0d1017;color:#e6e6e6}
main{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}
</style>
</head>
<body>
<main>
<h1>Access restricted</h1>
<p>This Paperclip deployment is gated by a bootstrap token.</p>
<p>Open the URL with <code>?${BOOTSTRAP_PARAM}=&lt;your token&gt;</code>
the value you set with <code>wrangler secret put BOOTSTRAP_TOKEN</code>.</p>
<p>Once the operator account is claimed, the operator can remove the gate
with <code>wrangler secret delete BOOTSTRAP_TOKEN</code>.</p>
</main>
</body>
</html>`;
}
/** True when a live Paperclip boot process already exists in the sandbox. */
export function isPaperclipRunning(processes: ProcessLike[]): boolean {
return processes.some(
(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<string, string> {
const env: Record<string, string> = {
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta http-equiv="refresh" content="10"/>
<title>Paperclip is starting</title>
<style>
:root{color-scheme:light dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,sans-serif;background:#0d1017;color:#e6e6e6}
main{text-align:center;padding:2rem}
.spinner{width:28px;height:28px;margin:0 auto 1.25rem;border-radius:50%;
border:3px solid #232838;border-top-color:#7dd3fc;animation:spin 1s linear infinite}
h1{font-size:1.1rem;font-weight:600;margin:0 0 .5rem}
p{margin:.25rem 0;color:#9aa4bf;font-size:.9rem}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<main>
<div class="spinner" role="status" aria-label="loading"></div>
<h1>Paperclip is starting</h1>
<p>The sandbox container is provisioning and Paperclip is onboarding its database.</p>
<p>First boot takes a minute or two. This page refreshes automatically.</p>
</main>
</body>
</html>`;
}
/** 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",
},
});
}

View File

@ -0,0 +1,96 @@
/**
* 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 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", () => {
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);
});
it("serializes duplicate boots with a non-blocking lock", () => {
expect(script).toContain("flock --nonblock");
});
});

View File

@ -0,0 +1,243 @@
import { describe, expect, it } from "vitest";
import {
BOOTSTRAP_PARAM,
PAPERCLIP_PORT,
PAPERCLIP_UID,
START_COMMAND,
STORAGE_MOUNT_PATH,
accessDeniedPage,
MAX_REQUEST_BYTES,
bootstrapGateMode,
decodeCookieValue,
exceedsRequestSizeLimit,
getCookie,
isOriginAllowed,
setupRequiredPage,
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("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");
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("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);
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");
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");
});
});

View File

@ -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"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});

View File

@ -0,0 +1,48 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "paperclip-sandbox",
"main": "src/index.ts",
"compatibility_date": "2026-08-29",
"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 (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-experimental.
// "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": "",
// 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 }
}

164
docs/deploy/cloudflare.md Normal file
View File

@ -0,0 +1,164 @@
---
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, 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
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 22+ (required by the pinned wrangler toolchain) and pnpm
## Deploy
```sh
cd deploy/cloudflare
pnpm install
npx wrangler login
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
```
Then open
`https://paperclip-sandbox.<your-subdomain>.workers.dev/?bootstrap_token=<your token>`.
The **first request** provisions the container and onboards Paperclip
(a minute or two) — you'll see a self-refreshing status page until the app is
up.
Paperclip boots in `authenticated` mode with a pending bootstrap invite, and
**the first visitor to reach the app can claim the operator account**. The
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.
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
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` |
| `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) |
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
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.

View File

@ -84,6 +84,7 @@
"deploy/local-development",
"deploy/tailscale-private-access",
"deploy/docker",
"deploy/cloudflare",
"deploy/deployment-modes",
"deploy/database",
"deploy/secrets",