Add Cloudflare sandbox provider plugin (#5687)

> _Stacked on top of #5685#5686. Diff against master includes commits
from earlier PRs in the stack — review focuses on the two new commits
(`Extend sandbox callback bridge for Worker-hosted plugins` + `Add
Cloudflare sandbox provider plugin`)._

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose which
provider backs that sandbox — today E2B and Daytona are bundled with the
platform
> - Cloudflare Workers + Durable Objects + the Sandbox SDK offer a
credible new option: globally distributed, cheap idle, and
operator-deployable as a single Worker
> - To plug it in, Paperclip needs (a) a provider plugin that speaks the
`PaperclipPluginManifestV1` lifecycle and (b) a small operator-deployed
Worker — the **bridge** — that adapts Paperclip's runtime RPCs to the
Cloudflare Sandbox SDK
> - The plugin extends the existing sandbox-callback-bridge with a
`bridge.transport: "worker"` discriminator so the platform routes
runtime RPCs through the Worker bridge instead of the in-process runner
> - This pull request adds the plugin, the bridge Worker template, and
the supporting adapter-utils + server hooks the new transport needs
> - The benefit is that operators can run sandboxes on Cloudflare's edge
with no new platform code beyond installing the plugin and deploying the
Worker

## What Changed

**Shared support (`Extend sandbox callback bridge for Worker-hosted
plugins`):**

- `packages/adapter-utils/src/sandbox-callback-bridge.{ts,test.ts}`:
expose `expectedHostHeader` so plugin-side bridge clients can verify the
canonical request envelope before forwarding.
- `packages/adapter-utils/src/command-managed-runtime.{ts,test.ts}`:
relax the always-fresh runner construction so callers can re-use a
runner across exec calls (Worker-hosted bridges hold the runner inside a
Durable Object).
- `server/src/services/environment-runtime.ts` +
`environment-runtime.test.ts`: route Worker-hosted bridges through the
same env-shaping path as E2B and pin the `requestEnv` contract.
- `server/src/services/plugin-environment-driver.ts`: thread an optional
`issueId` through the runtime descriptor so bridges can scope leases to
the originating issue (used by Cloudflare to map a sandbox to the
issue/workflow for billing and audit).
- `packages/plugins/sdk/src/protocol.ts`: add `issueId?` to
`PluginEnvironmentDriverBaseParams` and the new `bridge.transport:
"worker"` discriminator that the new plugin declares.
- `server/__tests__/heartbeat-plugin-environment.test.ts`: pin the
heartbeat path against the new runtime descriptor.

**The Cloudflare plugin itself (`Add Cloudflare sandbox provider
plugin`):**

- `packages/plugins/sandbox-providers/cloudflare/`: plugin entry,
manifest, plugin runtime (lifecycle + bridge client), config parsing,
and Vitest coverage. Manifest declares `bridge.transport: "worker"` so
the platform routes runtime RPCs through the bridge client.
- `bridge-template/`: a Worker template the operator deploys with
`wrangler`. Owns Durable Object-backed sessions (`sessions.ts`),
exec/stream routes (`exec.ts`, `routes.ts`), and an HMAC auth layer
(`auth.ts`) that pins the `Host` header surface. Includes the
SDK-contract-correct exec implementation, lease recovery, and chunked
stdout/stderr streaming.
- Tests cover lease/session handoff (`bridge-template/src/exec.test.ts`,
`routes.test.ts`), bridge client request shaping
(`src/bridge-client.test.ts`), and end-to-end plugin behavior
(`src/plugin.test.ts`) including streamed exec output. 27 tests in
total.
- `README.md` walks the operator through deploying the bridge Worker,
registering the plugin, and configuring the runtime.

## Verification

- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
packages/adapter-utils/src/sandbox-callback-bridge.test.ts
packages/adapter-utils/src/command-managed-runtime.test.ts
server/src/__tests__/environment-runtime.test.ts
server/src/__tests__/heartbeat-plugin-environment.test.ts`
- `(cd packages/plugins/sandbox-providers/cloudflare && pnpm test)` — 27
passing

For an operator-side smoke test:

1. Deploy the bridge: `cd
packages/plugins/sandbox-providers/cloudflare/bridge-template &&
wrangler deploy`
2. Register the plugin in your Paperclip instance, point its bridge URL
at the deployed Worker, set the HMAC shared secret.
3. Create a sandbox environment whose provider is `cloudflare`, then run
a Codex or Claude job against it.

## Risks

- Adds a new `bridge.transport: "worker"` code path, but the existing
E2B / Daytona transports go through the same shaped helpers and have
explicit test coverage that pins their behavior unchanged.
- The Worker bridge stores session state in a Durable Object; operator
instances must be aware of the corresponding Cloudflare costs (DO
requests, storage). Documented in the README.
- The `issueId` plumbing is optional throughout — existing plugins that
don't supply it continue to work.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, no UI change
- [x] I have updated relevant documentation to reflect my changes
(plugin README, bridge-template README)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-05-11 07:33:13 -07:00 committed by GitHub
parent 4ad1c83b84
commit 486fb88a15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3082 additions and 11 deletions

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isAuthorizedRequest, readBearerToken } from "./auth.js";
describe("bridge auth", () => {
it("extracts bearer tokens from Authorization headers", () => {
const request = new Request("https://bridge.example.test", {
headers: { Authorization: "Bearer secret-token" },
});
expect(readBearerToken(request)).toBe("secret-token");
});
it("rejects mismatched tokens", async () => {
const request = new Request("https://bridge.example.test", {
headers: { Authorization: "Bearer wrong-token" },
});
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
});
it("accepts matching tokens", async () => {
const request = new Request("https://bridge.example.test", {
headers: { Authorization: "Bearer expected-token" },
});
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(true);
});
it("rejects requests without an Authorization header", async () => {
const request = new Request("https://bridge.example.test");
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
});
});

View file

@ -0,0 +1,40 @@
export function readBearerToken(request: Request): string | null {
const header = request.headers.get("Authorization");
if (!header) return null;
const match = /^Bearer\s+(.+)$/i.exec(header);
return match?.[1]?.trim() || null;
}
// Compare two strings in constant time so an attacker can't infer the expected
// token character-by-character via response-latency timing. We hash both sides
// to SHA-256 first so the byte-by-byte comparison length is fixed (and doesn't
// leak the token's length), then walk the buffers with a constant-time XOR
// reduction. This avoids `crypto.subtle.timingSafeEqual` because that helper
// is not portable: it exists on Cloudflare Workers but is missing from Node's
// `crypto.subtle` (which would break unit tests). The manual XOR reduction on
// a fixed-length hash output is the same algorithm the helper uses internally.
async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
const encoder = new TextEncoder();
const [aHashBuf, bHashBuf] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(a)),
crypto.subtle.digest("SHA-256", encoder.encode(b)),
]);
const aBytes = new Uint8Array(aHashBuf);
const bBytes = new Uint8Array(bHashBuf);
if (aBytes.length !== bBytes.length) return false;
let diff = 0;
for (let i = 0; i < aBytes.length; i++) {
diff |= aBytes[i] ^ bBytes[i];
}
return diff === 0;
}
export async function isAuthorizedRequest(
request: Request,
expectedToken: string | undefined,
): Promise<boolean> {
if (!expectedToken || expectedToken.trim().length === 0) return false;
const presented = readBearerToken(request);
if (!presented) return false;
return timingSafeStringEqual(presented, expectedToken);
}

View file

@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@cloudflare/sandbox", () => ({
getSandbox: vi.fn(),
}));
import { buildLoginShellScript, executeInSandbox } from "./exec.js";
describe("bridge exec", () => {
it("invokes target.exec with a single shell command string and no args option", async () => {
const exec = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: "claude 1.0.0\n",
stderr: "",
});
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec }),
writeFile: vi.fn(),
deleteFile: vi.fn(),
} as const;
await executeInSandbox({
sandbox: sandbox as never,
command: "claude",
args: ["--version"],
cwd: "/workspace/paperclip",
env: { PAPERCLIP_TEST_FLAG: "1" },
sessionStrategy: "named",
sessionId: "paperclip",
timeoutMs: 12_345,
});
expect(exec).toHaveBeenCalledTimes(1);
const [commandArg, optionsArg] = exec.mock.calls[0] ?? [];
expect(typeof commandArg).toBe("string");
expect(commandArg).toMatch(/^sh -lc /);
expect(optionsArg).toEqual({ cwd: "/", timeout: 12_345 });
expect(optionsArg).not.toHaveProperty("args");
expect(optionsArg).not.toHaveProperty("stdin");
expect(commandArg).toContain('. /etc/profile');
expect(commandArg).toContain("cd ");
expect(commandArg).toContain("/workspace/paperclip");
expect(commandArg).toContain("PAPERCLIP_TEST_FLAG");
expect(commandArg).toContain("claude");
expect(commandArg).toContain("--version");
});
it("requests streaming callbacks when bridge output forwarding is enabled", async () => {
const exec = vi.fn().mockImplementation(async (_command, options) => {
await options?.onOutput?.("stdout", "hello\n");
return {
exitCode: 0,
stdout: "hello\n",
stderr: "",
};
});
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec }),
writeFile: vi.fn(),
deleteFile: vi.fn(),
} as const;
const onOutput = vi.fn();
await executeInSandbox({
sandbox: sandbox as never,
command: "echo",
args: ["hello"],
sessionStrategy: "named",
sessionId: "paperclip",
timeoutMs: 5_000,
onOutput,
});
expect(exec).toHaveBeenCalledTimes(1);
expect(exec.mock.calls[0]?.[1]).toMatchObject({
cwd: "/",
timeout: 5_000,
stream: true,
onOutput: expect.any(Function),
});
expect(onOutput).toHaveBeenCalledWith("stdout", "hello\n");
});
it("stages stdin through a sandbox temp file and redirects from it", async () => {
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
const writeFile = vi.fn().mockResolvedValue(undefined);
const deleteFile = vi.fn().mockResolvedValue(undefined);
// sessionStrategy: "default" routes through the sandbox itself (no
// getSession wrapper), so exec must live directly on the sandbox.
const sandbox = {
exec,
getSession: vi.fn(),
writeFile,
deleteFile,
} as const;
await executeInSandbox({
sandbox: sandbox as never,
command: "cat",
args: [],
sessionStrategy: "default",
timeoutMs: 5_000,
stdin: "payload-bytes",
});
expect(writeFile).toHaveBeenCalledTimes(1);
const [stdinPath, stdinPayload] = writeFile.mock.calls[0] ?? [];
expect(typeof stdinPath).toBe("string");
expect(stdinPath).toMatch(/^\/tmp\/\.paperclip-bridge-stdin-/);
expect(stdinPayload).toBe("payload-bytes");
const commandArg = exec.mock.calls[0]?.[0];
expect(commandArg).toContain(stdinPath);
expect(commandArg).toMatch(/<\s*['"]/);
expect(deleteFile).toHaveBeenCalledWith(stdinPath);
});
it("does not write a stdin file or redirect when stdin is empty", async () => {
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
const writeFile = vi.fn();
const deleteFile = vi.fn();
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec }),
writeFile,
deleteFile,
} as const;
await executeInSandbox({
sandbox: sandbox as never,
command: "pwd",
sessionStrategy: "named",
sessionId: "paperclip",
timeoutMs: 5_000,
stdin: null,
});
expect(writeFile).not.toHaveBeenCalled();
expect(deleteFile).not.toHaveBeenCalled();
const commandArg = exec.mock.calls[0]?.[0];
expect(commandArg).not.toContain("<");
});
it("rejects invalid environment variable keys in the login-shell wrapper", () => {
expect(() => buildLoginShellScript({
command: "pwd",
args: [],
env: { "bad-key": "1" },
})).toThrow("Invalid sandbox environment variable key: bad-key");
});
});

View file

@ -0,0 +1,147 @@
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
import { shellQuote } from "./helpers.js";
import { isTimeoutError } from "./sandboxes.js";
import { cleanupTimedOutExecution, resolveExecutionTarget, type SessionStrategy } from "./sessions.js";
export interface BridgeExecuteParams {
sandbox: CloudflareSandbox;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string | null;
timeoutMs?: number;
sessionStrategy: SessionStrategy;
sessionId?: string;
onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function randomToken(): string {
const uuid = globalThis.crypto?.randomUUID?.();
if (typeof uuid === "string" && uuid.length > 0) return uuid.replace(/[^a-zA-Z0-9-]/g, "");
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function buildLoginShellScript(input: {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
stdinFile?: string | null;
}): string {
const env = input.env ?? {};
for (const key of Object.keys(env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid sandbox environment variable key: ${key}`);
}
}
const envArgs = Object.entries(env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const stdinRedirect = input.stdinFile ? ` < ${shellQuote(input.stdinFile)}` : "";
const lines = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
}
const execLine = envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${commandParts}${stdinRedirect}`
: `exec ${commandParts}${stdinRedirect}`;
lines.push(execLine);
return lines.join(" && ");
}
function coerceExecuteResult(result: {
success?: boolean;
stdout?: string;
stderr?: string;
exitCode?: number | null;
}) {
return {
exitCode:
typeof result.exitCode === "number" || result.exitCode === null
? result.exitCode
: result.success === false
? 1
: 0,
signal: null,
timedOut: false,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
export async function executeInSandbox(params: BridgeExecuteParams) {
// The @cloudflare/sandbox SDK's exec() takes a single command string and a
// narrow option set ({ cwd, env, timeout, ... }) — it does not accept `args`
// or `stdin`. We compose the full shell command ourselves and stage stdin
// through a temp file in the sandbox when the caller provides one.
const stdinPayload = typeof params.stdin === "string" && params.stdin.length > 0
? params.stdin
: null;
const stdinFile = stdinPayload ? `/tmp/.paperclip-bridge-stdin-${randomToken()}` : null;
if (stdinFile && stdinPayload) {
await params.sandbox.writeFile(stdinFile, stdinPayload, { encoding: "utf8" });
}
try {
const target = await resolveExecutionTarget(params.sandbox, {
sessionStrategy: params.sessionStrategy,
sessionId: params.sessionId,
cwd: params.cwd,
env: params.env,
timeoutMs: params.timeoutMs,
});
const script = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
cwd: params.cwd,
env: params.env,
stdinFile,
});
const fullCommand = `sh -lc ${shellQuote(script)}`;
const result = await target.exec(fullCommand, {
cwd: "/",
timeout: params.timeoutMs,
...(typeof params.onOutput === "function"
? {
stream: true,
onOutput: params.onOutput,
}
: {}),
});
return coerceExecuteResult(result);
} catch (error) {
if (isTimeoutError(error)) {
await cleanupTimedOutExecution(params.sandbox, {
sessionStrategy: params.sessionStrategy,
sessionId: params.sessionId,
});
return {
exitCode: null,
signal: null,
timedOut: true,
stdout: typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "",
stderr: `${error instanceof Error ? error.message : String(error)}\n`,
};
}
throw error;
} finally {
if (stdinFile) {
await params.sandbox.deleteFile?.(stdinFile).catch(() => undefined);
}
}
}

View file

@ -0,0 +1,39 @@
export function normalizeLeaseIdPart(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
}
export function buildLeaseSandboxId(input: {
environmentId: string;
runId: string;
reuseLease: boolean;
normalizeId: boolean;
randomId?: string;
}): string {
const base = input.reuseLease
? `pc-env-${input.environmentId}`
: `pc-${input.runId}-${input.randomId ?? crypto.randomUUID().slice(0, 8)}`;
return input.normalizeId ? normalizeLeaseIdPart(base) : base;
}
export function buildSentinelPath(remoteCwd: string): string {
return `${remoteCwd.replace(/\/+$/, "")}/.paperclip-lease.json`;
}
export function isTimeoutError(error: unknown): boolean {
const name = (error as { name?: string } | null)?.name ?? "";
const message = error instanceof Error ? error.message : String(error);
return /timeout/i.test(name) || /timed out|timeout/i.test(message);
}
// Single-quote `value` for safe inclusion in a `sh -c` script. Single
// quotes inside the value are escaped via the standard `'"'"'` dance.
// Used by both `routes.ts` and `exec.ts` — keep one copy here so updates
// (e.g. handling additional shell special characters) stay in sync.
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}

View file

@ -0,0 +1,25 @@
import { Sandbox } from "@cloudflare/sandbox";
import { handleBridgeRequest, } from "./routes.js";
import type { BridgeEnv } from "./sandboxes.js";
export { Sandbox };
export default {
async fetch(request: Request, env: BridgeEnv): Promise<Response> {
try {
return await handleBridgeRequest(request, env);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(
JSON.stringify({
error: "internal_error",
message,
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
},
};

View file

@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@cloudflare/sandbox", () => ({
getSandbox: vi.fn(),
}));
import { handleBridgeRequest } from "./routes.js";
import { resolveSandbox } from "./sandboxes.js";
vi.mock("./sandboxes.js", async () => {
const actual = await vi.importActual<typeof import("./sandboxes.js")>("./sandboxes.js");
return {
...actual,
resolveSandbox: vi.fn(),
};
});
function bridgeRequest(pathname: string, body: unknown): Request {
return new Request(`https://bridge.example.test${pathname}`, {
method: "POST",
headers: {
Authorization: "Bearer secret-token",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
describe("bridge routes", () => {
beforeEach(() => {
vi.mocked(resolveSandbox).mockReset();
});
it("writes lease sentinels through the named-session exec target", async () => {
const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
createSession: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
setKeepAlive: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
const response = await handleBridgeRequest(
bridgeRequest("/api/paperclip-sandbox/v1/leases/acquire", {
environmentId: "env-1",
runId: "run-1",
requestedCwd: "/workspace/paperclip",
sessionStrategy: "named",
sessionId: "paperclip",
}),
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
);
expect(response.status).toBe(200);
// Sentinel write must NOT use sandbox.writeFile (sandbox-level race);
// it goes through the same session as the mkdir.
expect(sandbox.writeFile).not.toHaveBeenCalled();
// Both calls use a single command string — the SDK's exec API ignores
// any `args` or `stdin` option, so the bridge folds them into the
// command line itself.
expect(sessionExec).toHaveBeenCalledTimes(2);
for (const call of sessionExec.mock.calls) {
const [commandArg, optionsArg] = call;
expect(typeof commandArg).toBe("string");
expect(commandArg).toMatch(/^sh -lc /);
expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
expect(optionsArg).not.toHaveProperty("args");
expect(optionsArg).not.toHaveProperty("stdin");
}
expect(sessionExec.mock.calls[0]?.[0]).toContain("mkdir");
expect(sessionExec.mock.calls[0]?.[0]).toContain("/workspace/paperclip");
expect(sessionExec.mock.calls[1]?.[0]).toContain("/workspace/paperclip/.paperclip-lease.json");
});
it("checks lease sentinels through the named-session exec target on resume", async () => {
const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
createSession: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
setKeepAlive: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
const response = await handleBridgeRequest(
bridgeRequest("/api/paperclip-sandbox/v1/leases/resume", {
providerLeaseId: "pc-run-1-abcd1234",
requestedCwd: "/workspace/paperclip",
sessionStrategy: "named",
sessionId: "paperclip",
}),
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
);
expect(response.status).toBe(200);
expect(sandbox.readFile).not.toHaveBeenCalled();
const [commandArg, optionsArg] = sessionExec.mock.calls[0] ?? [];
expect(typeof commandArg).toBe("string");
expect(commandArg).toMatch(/^sh -lc /);
expect(commandArg).toContain("test -s");
expect(commandArg).toContain("/workspace/paperclip/.paperclip-lease.json");
expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
expect(optionsArg).not.toHaveProperty("args");
});
it("streams exec stdout and completion metadata when requested", async () => {
const sessionExec = vi.fn().mockImplementation(async (_command, options) => {
await options?.onOutput?.("stdout", "hello\n");
return { exitCode: 0, stdout: "hello\n", stderr: "" };
});
const sandbox = {
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
createSession: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
setKeepAlive: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
const response = await handleBridgeRequest(
bridgeRequest("/api/paperclip-sandbox/v1/exec", {
providerLeaseId: "pc-run-1-abcd1234",
command: "echo",
args: ["hello"],
sessionStrategy: "named",
sessionId: "paperclip",
streamOutput: true,
}),
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toContain("text/event-stream");
const body = await response.text();
expect(body).toContain("event: stdout");
expect(body).toContain("event: complete");
});
});

View file

@ -0,0 +1,468 @@
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
import { isAuthorizedRequest } from "./auth.js";
import { executeInSandbox } from "./exec.js";
import { shellQuote } from "./helpers.js";
import {
buildLeaseSandboxId,
buildSentinelPath,
DEFAULT_REMOTE_CWD,
DEFAULT_SESSION_ID,
DEFAULT_TIMEOUT_MS,
resolveSandbox,
applySandboxKeepAlive,
toErrorResponse,
toJsonResponse,
type BridgeEnv,
} from "./sandboxes.js";
import type { SessionStrategy } from "./sessions.js";
interface ProbeRequestBody {
requestedCwd?: string;
keepAlive?: boolean;
sleepAfter?: string;
normalizeId?: boolean;
sessionStrategy?: SessionStrategy;
sessionId?: string;
timeoutMs?: number;
}
interface AcquireLeaseRequestBody extends ProbeRequestBody {
environmentId?: string;
runId?: string;
issueId?: string | null;
reuseLease?: boolean;
}
interface ResumeLeaseRequestBody extends ProbeRequestBody {
providerLeaseId?: string;
}
interface ReleaseLeaseRequestBody {
providerLeaseId?: string;
reuseLease?: boolean;
keepAlive?: boolean;
}
interface ExecuteRequestBody {
providerLeaseId?: string;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string | null;
timeoutMs?: number;
streamOutput?: boolean;
sessionStrategy?: SessionStrategy;
sessionId?: string;
}
function readBoolean(value: unknown, fallback: boolean): boolean {
return value === undefined ? fallback : value === true;
}
function readString(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
}
function readInteger(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
function readSessionStrategy(value: unknown): SessionStrategy {
return value === "default" ? "default" : "named";
}
async function readJson<T>(request: Request): Promise<T> {
return await request.json() as T;
}
function encodeSseEvent(type: string, payload: unknown): string {
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
function toSseResponse(stream: ReadableStream<Uint8Array>): Response {
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
async function execLeaseUtility(
sandbox: CloudflareSandbox,
options: {
remoteCwd: string;
sessionStrategy: SessionStrategy;
sessionId: string;
timeoutMs: number;
},
command: string,
args: string[],
cwd = "/",
) {
return await executeInSandbox({
sandbox,
command,
args,
cwd,
timeoutMs: options.timeoutMs,
sessionStrategy: options.sessionStrategy,
sessionId: options.sessionId,
});
}
function requireZeroExit(action: string, result: { exitCode: number | null; timedOut: boolean; stderr: string }) {
if (result.timedOut) {
throw new Error(`${action} timed out: ${result.stderr.trim()}`);
}
if (result.exitCode !== 0) {
throw new Error(
`${action} failed with exit code ${result.exitCode ?? "null"}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`,
);
}
}
async function ensureWorkspace(
sandbox: CloudflareSandbox,
options: {
remoteCwd: string;
sessionStrategy: SessionStrategy;
sessionId: string;
timeoutMs: number;
},
) {
const result = await execLeaseUtility(sandbox, options, "mkdir", ["-p", options.remoteCwd], "/");
requireZeroExit(`ensure workspace ${options.remoteCwd}`, result);
}
async function writeSentinel(
sandbox: CloudflareSandbox,
input: {
providerLeaseId: string;
remoteCwd: string;
sessionStrategy: SessionStrategy;
sessionId: string;
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
resumedLease: boolean;
timeoutMs: number;
},
) {
const sentinelPayload = JSON.stringify({
provider: "cloudflare",
providerLeaseId: input.providerLeaseId,
remoteCwd: input.remoteCwd,
sessionStrategy: input.sessionStrategy,
sessionId: input.sessionId,
keepAlive: input.keepAlive,
sleepAfter: input.sleepAfter,
normalizeId: input.normalizeId,
resumedLease: input.resumedLease,
updatedAt: new Date().toISOString(),
}, null, 2);
const sentinelPath = buildSentinelPath(input.remoteCwd);
const result = await execLeaseUtility(
sandbox,
input,
"sh",
[
"-c",
`mkdir -p ${shellQuote(input.remoteCwd)} && printf '%s\\n' ${shellQuote(sentinelPayload)} > ${shellQuote(sentinelPath)}`,
],
"/",
);
requireZeroExit(`write sentinel ${sentinelPath}`, result);
}
async function verifySentinel(
sandbox: CloudflareSandbox,
input: {
remoteCwd: string;
sessionStrategy: SessionStrategy;
sessionId: string;
timeoutMs: number;
},
): Promise<boolean> {
const result = await execLeaseUtility(
sandbox,
input,
"sh",
["-c", `test -s ${shellQuote(buildSentinelPath(input.remoteCwd))}`],
"/",
);
return !result.timedOut && (result.exitCode ?? 0) === 0;
}
export async function handleBridgeRequest(request: Request, env: BridgeEnv): Promise<Response> {
if (!(await isAuthorizedRequest(request, env.BRIDGE_AUTH_TOKEN))) {
return toErrorResponse(401, "unauthorized", "Missing or invalid bridge bearer token.");
}
const url = new URL(request.url);
const pathname = url.pathname.replace(/\/+$/, "");
if (request.method === "GET" && pathname === "/api/paperclip-sandbox/v1/health") {
return toJsonResponse({
ok: true,
provider: "cloudflare",
bridgeVersion: "0.1.0",
capabilities: {
reuseLease: true,
namedSessions: true,
previewUrls: false,
},
});
}
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/probe") {
const body = await readJson<ProbeRequestBody>(request);
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
const keepAlive = readBoolean(body.keepAlive, false);
const sleepAfter = readString(body.sleepAfter, "10m");
const normalizeId = readBoolean(body.normalizeId, true);
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
const sandboxId = buildLeaseSandboxId({
environmentId: "probe",
runId: `probe-${Date.now()}`,
reuseLease: false,
normalizeId,
});
const sandbox = await resolveSandbox(env, sandboxId, { keepAlive, sleepAfter, normalizeId });
await applySandboxKeepAlive(sandbox, keepAlive);
try {
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
const result = await executeInSandbox({
sandbox,
command: "pwd",
cwd: remoteCwd,
timeoutMs,
sessionStrategy,
sessionId,
});
return toJsonResponse({
ok: true,
summary: "Connected to Cloudflare sandbox bridge.",
metadata: {
provider: "cloudflare",
remoteCwd,
namedSessions: sessionStrategy === "named",
stdout: result.stdout,
},
});
} finally {
await sandbox.destroy().catch(() => undefined);
}
}
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/acquire") {
const body = await readJson<AcquireLeaseRequestBody>(request);
if (!body.environmentId || !body.runId) {
return toErrorResponse(400, "invalid_request", "environmentId and runId are required.");
}
const reuseLease = readBoolean(body.reuseLease, false);
const keepAlive = readBoolean(body.keepAlive, false);
const sleepAfter = readString(body.sleepAfter, "10m");
const normalizeId = readBoolean(body.normalizeId, true);
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
const providerLeaseId = buildLeaseSandboxId({
environmentId: body.environmentId,
runId: body.runId,
reuseLease,
normalizeId,
});
const sandbox = await resolveSandbox(env, providerLeaseId, { keepAlive, sleepAfter, normalizeId });
// Guard against orphaning a keepAlive sandbox if workspace setup throws
// after creation: Paperclip never sees the lease ID in that case, so it
// can't clean up. Destroy here unless this is a reuseLease handshake
// (where the sandbox may have been created by a prior acquire and we
// shouldn't destroy it on a transient setup failure during reattachment).
try {
await applySandboxKeepAlive(sandbox, keepAlive);
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
await writeSentinel(sandbox, {
providerLeaseId,
remoteCwd,
sessionStrategy,
sessionId,
keepAlive,
sleepAfter,
normalizeId,
resumedLease: false,
timeoutMs,
});
} catch (err) {
if (!reuseLease) {
await sandbox.destroy().catch(() => undefined);
}
throw err;
}
return toJsonResponse({
providerLeaseId,
metadata: {
provider: "cloudflare",
remoteCwd,
sandboxId: providerLeaseId,
sessionStrategy,
sessionId,
keepAlive,
sleepAfter,
normalizeId,
resumedLease: false,
},
});
}
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/resume") {
const body = await readJson<ResumeLeaseRequestBody>(request);
if (!body.providerLeaseId) {
return toErrorResponse(400, "invalid_request", "providerLeaseId is required.");
}
const keepAlive = readBoolean(body.keepAlive, false);
const sleepAfter = readString(body.sleepAfter, "10m");
const normalizeId = readBoolean(body.normalizeId, true);
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
const sandbox = await resolveSandbox(env, body.providerLeaseId, { keepAlive, sleepAfter, normalizeId });
// Resume always reattaches to a providerLeaseId the operator already
// owns, so we deliberately do NOT destroy on failure here — the operator
// has the ID and can issue an explicit release/destroy. Calling
// `getSandbox` is idempotent on the Sandbox SDK side (no new sandbox is
// created), so a failed resume doesn't leak a *new* sandbox.
await applySandboxKeepAlive(sandbox, keepAlive);
if (!(await verifySentinel(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs }))) {
return toErrorResponse(409, "sandbox_state_lost", "Cloudflare sandbox state is no longer available.");
}
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
await writeSentinel(sandbox, {
providerLeaseId: body.providerLeaseId,
remoteCwd,
sessionStrategy,
sessionId,
keepAlive,
sleepAfter,
normalizeId,
resumedLease: true,
timeoutMs,
});
return toJsonResponse({
providerLeaseId: body.providerLeaseId,
metadata: {
provider: "cloudflare",
remoteCwd,
sandboxId: body.providerLeaseId,
sessionStrategy,
sessionId,
keepAlive,
sleepAfter,
normalizeId,
resumedLease: true,
},
});
}
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/release") {
const body = await readJson<ReleaseLeaseRequestBody>(request);
if (!body.providerLeaseId) {
return toJsonResponse({ ok: true });
}
if (readBoolean(body.reuseLease, false)) {
return toJsonResponse({ ok: true });
}
const sandbox = await resolveSandbox(env, body.providerLeaseId, {
keepAlive: readBoolean(body.keepAlive, false),
sleepAfter: "10m",
normalizeId: true,
});
await sandbox.destroy().catch(() => undefined);
return toJsonResponse({ ok: true });
}
if (request.method === "DELETE" && pathname.startsWith("/api/paperclip-sandbox/v1/leases/")) {
const providerLeaseId = decodeURIComponent(pathname.split("/").pop() ?? "");
if (providerLeaseId.length === 0) {
return toErrorResponse(400, "invalid_request", "providerLeaseId path parameter is required.");
}
const sandbox = await resolveSandbox(env, providerLeaseId, {
keepAlive: false,
sleepAfter: "10m",
normalizeId: true,
});
await sandbox.destroy().catch(() => undefined);
return toJsonResponse({ ok: true });
}
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/exec") {
const body = await readJson<ExecuteRequestBody>(request);
if (!body.providerLeaseId || !body.command) {
return toErrorResponse(400, "invalid_request", "providerLeaseId and command are required.");
}
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
const sandbox = await resolveSandbox(env, body.providerLeaseId, {
keepAlive: false,
sleepAfter: "10m",
normalizeId: true,
});
if (body.streamOutput === true) {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
const result = await executeInSandbox({
sandbox,
command: body.command!,
args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
env: body.env,
stdin: body.stdin ?? null,
timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
sessionStrategy,
sessionId,
onOutput: async (streamName, data) => {
controller.enqueue(encoder.encode(encodeSseEvent(streamName, { data })));
},
});
controller.enqueue(encoder.encode(encodeSseEvent("complete", result)));
} catch (error) {
controller.enqueue(encoder.encode(encodeSseEvent("error", {
error: error instanceof Error ? error.message : String(error),
})));
} finally {
controller.close();
}
},
});
return toSseResponse(stream);
}
const result = await executeInSandbox({
sandbox,
command: body.command,
args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
env: body.env,
stdin: body.stdin ?? null,
timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
sessionStrategy,
sessionId,
});
return toJsonResponse(result);
}
return toErrorResponse(404, "not_found", `No bridge route matched ${request.method} ${pathname}.`);
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
describe("bridge sandbox helpers", () => {
it("builds reusable lease IDs from environment IDs", () => {
expect(buildLeaseSandboxId({
environmentId: "Env_123",
runId: "run-ignored",
reuseLease: true,
normalizeId: true,
})).toBe("pc-env-env-123");
});
it("builds ephemeral lease IDs from run IDs", () => {
expect(buildLeaseSandboxId({
environmentId: "env-1",
runId: "Run_123",
reuseLease: false,
normalizeId: true,
randomId: "ABCD1234",
})).toBe("pc-run-123-abcd1234");
});
it("builds the workspace sentinel path", () => {
expect(buildSentinelPath("/workspace/paperclip/")).toBe("/workspace/paperclip/.paperclip-lease.json");
});
it("detects timeout-shaped errors", () => {
expect(isTimeoutError(new Error("command timed out after 10s"))).toBe(true);
expect(isTimeoutError(new Error("some other error"))).toBe(false);
});
});

View file

@ -0,0 +1,57 @@
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
import { getSandbox } from "@cloudflare/sandbox";
import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
export interface BridgeEnv {
Sandbox: DurableObjectNamespace<CloudflareSandbox>;
BRIDGE_AUTH_TOKEN?: string;
}
export interface BridgeLeaseConfig {
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
}
export const DEFAULT_REMOTE_CWD = "/workspace/paperclip";
export const DEFAULT_SESSION_ID = "paperclip";
export const DEFAULT_TIMEOUT_MS = 300_000;
export const LEASE_SENTINEL_FILE = ".paperclip-lease.json";
export function toJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
},
});
}
export function toErrorResponse(status: number, error: string, message: string, details?: unknown): Response {
return toJsonResponse({ error, message, details }, status);
}
export async function resolveSandbox(
env: BridgeEnv,
sandboxId: string,
config: BridgeLeaseConfig,
): Promise<CloudflareSandbox> {
// Pure handle resolution: the constructor accepts keepAlive/sleepAfter so the
// sandbox is created with the right defaults on first use, but we no longer
// call `setKeepAlive` here. That side effect now lives in
// `applySandboxKeepAlive` and is invoked only from lease-management routes,
// so exec calls don't accidentally overwrite the lease's keepAlive policy.
return getSandbox(env.Sandbox, sandboxId, {
keepAlive: config.keepAlive,
sleepAfter: config.sleepAfter,
});
}
export async function applySandboxKeepAlive(
sandbox: CloudflareSandbox,
keepAlive: boolean,
): Promise<void> {
await sandbox.setKeepAlive(keepAlive);
}
export { buildLeaseSandboxId, buildSentinelPath, isTimeoutError };

View file

@ -0,0 +1,84 @@
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
import { DEFAULT_SESSION_ID } from "./sandboxes.js";
export type SessionStrategy = "named" | "default";
export interface ResolvedSession {
exec(
command: string,
options?: {
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string | null;
timeout?: number;
stream?: boolean;
onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
},
): Promise<{ success?: boolean; stdout?: string; stderr?: string; exitCode?: number | null }>;
}
export async function getNamedSession(
sandbox: CloudflareSandbox,
options: {
sessionId?: string;
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
},
): Promise<ResolvedSession> {
const sessionId = options.sessionId?.trim() || DEFAULT_SESSION_ID;
try {
return await sandbox.getSession(sessionId);
} catch (err) {
// Only fall through to `createSession` for the "session not found" case.
// The Sandbox SDK currently surfaces missing-session as an Error whose
// message contains "not found" / "does not exist"; any other failure
// (quota exceeded, sandbox destroyed mid-request, malformed ID) should
// bubble up so callers see the real cause instead of a confusing
// secondary `createSession` error that hides the root cause.
if (!isSessionNotFoundError(err)) throw err;
// Create the session without pinning it to a workspace path up front.
// Workspace preparation may be the first thing we do with the session.
return await sandbox.createSession({
id: sessionId,
env: options.env,
commandTimeoutMs: options.timeoutMs,
});
}
}
function isSessionNotFoundError(err: unknown): boolean {
if (!err) return false;
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "";
return /not\s*found|does\s*not\s*exist|no\s+such\s+session/i.test(message);
}
export async function resolveExecutionTarget(
sandbox: CloudflareSandbox,
options: {
sessionStrategy: SessionStrategy;
sessionId?: string;
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
},
): Promise<ResolvedSession | CloudflareSandbox> {
if (options.sessionStrategy === "default") return sandbox;
return await getNamedSession(sandbox, options);
}
export async function cleanupTimedOutExecution(
sandbox: CloudflareSandbox,
options: {
sessionStrategy: SessionStrategy;
sessionId?: string;
},
): Promise<void> {
if (options.sessionStrategy === "default") {
await sandbox.destroy().catch(() => undefined);
return;
}
await sandbox.deleteSession(options.sessionId?.trim() || DEFAULT_SESSION_ID).catch(() => undefined);
}