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,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CloudflareDriverConfig } from "./types.js";
import { createCloudflareBridgeClient, resolveRequestTimeoutMs } from "./bridge-client.js";
const baseConfig: CloudflareDriverConfig = {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "secret-ref://bridge-token",
reuseLease: false,
keepAlive: false,
sleepAfter: "10m",
normalizeId: true,
requestedCwd: "/workspace/paperclip",
sessionStrategy: "named",
sessionId: "paperclip",
timeoutMs: 300_000,
bridgeRequestTimeoutMs: 30_000,
previewHostname: null,
};
describe("Cloudflare bridge client timeouts", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("keeps the configured timeout for non-exec requests", () => {
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/probe", {
method: "POST",
body: JSON.stringify({ timeoutMs: 270_000 }),
})).toBe(30_000);
});
it("extends exec requests to the command timeout when needed", () => {
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
method: "POST",
body: JSON.stringify({ command: "opencode", timeoutMs: 270_000 }),
})).toBe(270_000);
});
it("falls back to the configured timeout when exec timeout is missing or smaller", () => {
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
method: "POST",
body: JSON.stringify({ command: "pwd" }),
})).toBe(30_000);
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
method: "POST",
body: JSON.stringify({ command: "pwd", timeoutMs: 5_000 }),
})).toBe(30_000);
});
it("consumes streamed exec output and returns the final result", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
[
'event: stdout',
'data: {"data":"hello\\n"}',
"",
'event: complete',
'data: {"exitCode":0,"signal":null,"timedOut":false,"stdout":"hello\\n","stderr":""}',
"",
].join("\n"),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
},
));
vi.stubGlobal("fetch", fetchMock);
const client = createCloudflareBridgeClient({ config: baseConfig });
const onOutput = vi.fn();
const result = await client.execute(
{
providerLeaseId: "lease-1",
command: "echo",
args: ["hello"],
sessionStrategy: "named",
sessionId: "paperclip",
},
{},
{ onOutput },
);
expect(result).toEqual({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "hello\n",
stderr: "",
});
expect(onOutput).toHaveBeenCalledWith("stdout", "hello\n");
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toMatchObject({ streamOutput: true });
});
});

View file

@ -0,0 +1,357 @@
import type {
CloudflareBridgeAcquireLeaseRequest,
CloudflareBridgeExecuteRequest,
CloudflareBridgeExecuteResponse,
CloudflareBridgeHealthResponse,
CloudflareBridgeLeaseResponse,
CloudflareBridgeProbeRequest,
CloudflareBridgeProbeResponse,
CloudflareBridgeReleaseLeaseRequest,
CloudflareBridgeResumeLeaseRequest,
CloudflareDriverConfig,
} from "./types.js";
interface BridgeClientHeaders {
environmentId?: string;
runId?: string;
issueId?: string | null;
}
interface BridgeClientOptions {
config: CloudflareDriverConfig;
}
interface BridgeExecuteOptions {
onOutput?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
}
interface BridgeErrorBody {
error?: string;
message?: string;
details?: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export class CloudflareBridgeError extends Error {
readonly status: number;
readonly code: string | null;
readonly details: unknown;
constructor(input: { status: number; code?: string | null; message: string; details?: unknown }) {
super(input.message);
this.name = "CloudflareBridgeError";
this.status = input.status;
this.code = input.code ?? null;
this.details = input.details;
}
}
function buildHeaders(config: CloudflareDriverConfig, extra: BridgeClientHeaders = {}): Headers {
const headers = new Headers();
headers.set("Authorization", `Bearer ${config.bridgeAuthToken}`);
headers.set("Content-Type", "application/json");
if (extra.environmentId) headers.set("X-Paperclip-Environment-Id", extra.environmentId);
if (extra.runId) headers.set("X-Paperclip-Run-Id", extra.runId);
if (extra.issueId) headers.set("X-Paperclip-Issue-Id", extra.issueId);
return headers;
}
async function parseJson(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return null;
}
return await response.json();
}
function encodeExecuteRequestBody(body: CloudflareBridgeExecuteRequest, options?: BridgeExecuteOptions): string {
return JSON.stringify({
...body,
streamOutput: typeof options?.onOutput === "function",
});
}
function parseExecuteTimeoutMs(body: RequestInit["body"]): number | null {
if (typeof body !== "string") return null;
try {
const parsed = JSON.parse(body) as { timeoutMs?: unknown };
const timeoutMs = Number(parsed.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : null;
} catch {
return null;
}
}
export function resolveRequestTimeoutMs(
config: CloudflareDriverConfig,
path: string,
init: RequestInit,
): number {
if (!path.endsWith("/exec")) {
return config.bridgeRequestTimeoutMs;
}
const requestedTimeoutMs = parseExecuteTimeoutMs(init.body);
return requestedTimeoutMs === null
? config.bridgeRequestTimeoutMs
: Math.max(config.bridgeRequestTimeoutMs, requestedTimeoutMs);
}
async function requestJson<T>(
config: CloudflareDriverConfig,
path: string,
init: RequestInit,
extraHeaders: BridgeClientHeaders = {},
): Promise<T> {
const controller = new AbortController();
const requestTimeoutMs = resolveRequestTimeoutMs(config, path, init);
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
const baseUrl = config.bridgeBaseUrl.replace(/\/+$/, "");
try {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: buildHeaders(config, extraHeaders),
signal: controller.signal,
});
const body = await parseJson(response);
if (!response.ok) {
const errorBody = isRecord(body) ? body as BridgeErrorBody : {};
throw new CloudflareBridgeError({
status: response.status,
code: typeof errorBody.error === "string" ? errorBody.error : null,
message:
typeof errorBody.message === "string" && errorBody.message.trim().length > 0
? errorBody.message
: `Cloudflare sandbox bridge request failed with HTTP ${response.status}.`,
details: errorBody.details,
});
}
return body as T;
} catch (error) {
if (error instanceof CloudflareBridgeError) throw error;
if ((error as { name?: string } | null)?.name === "AbortError") {
throw new Error(
`Cloudflare sandbox bridge request timed out after ${requestTimeoutMs}ms.`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function requestResponse(
config: CloudflareDriverConfig,
path: string,
init: RequestInit,
extraHeaders: BridgeClientHeaders = {},
): Promise<Response> {
const controller = new AbortController();
const requestTimeoutMs = resolveRequestTimeoutMs(config, path, init);
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
const baseUrl = config.bridgeBaseUrl.replace(/\/+$/, "");
try {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: buildHeaders(config, extraHeaders),
signal: controller.signal,
});
if (!response.ok) {
const body = await parseJson(response);
const errorBody = isRecord(body) ? body as BridgeErrorBody : {};
throw new CloudflareBridgeError({
status: response.status,
code: typeof errorBody.error === "string" ? errorBody.error : null,
message:
typeof errorBody.message === "string" && errorBody.message.trim().length > 0
? errorBody.message
: `Cloudflare sandbox bridge request failed with HTTP ${response.status}.`,
details: errorBody.details,
});
}
return response;
} catch (error) {
if (error instanceof CloudflareBridgeError) throw error;
if ((error as { name?: string } | null)?.name === "AbortError") {
throw new Error(
`Cloudflare sandbox bridge request timed out after ${requestTimeoutMs}ms.`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
interface ParsedSseEvent {
event: string;
data: string;
}
function parseSseChunk(buffer: string): { events: ParsedSseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const frames = normalized.split("\n\n");
const rest = frames.pop() ?? "";
const events: ParsedSseEvent[] = [];
for (const frame of frames) {
let event = "message";
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
events.push({
event,
data: dataLines.join("\n"),
});
}
return { events, rest };
}
async function consumeExecuteEventStream(
response: Response,
options: BridgeExecuteOptions,
): Promise<CloudflareBridgeExecuteResponse> {
if (!response.body) {
throw new Error("Cloudflare sandbox bridge streaming response had no body.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result: CloudflareBridgeExecuteResponse | null = null;
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const parsed = parseSseChunk(done && buffer.length > 0 ? `${buffer}\n\n` : buffer);
buffer = parsed.rest;
for (const event of parsed.events) {
if (event.event === "stdout" || event.event === "stderr") {
const payload = JSON.parse(event.data) as { data?: unknown };
const chunk = typeof payload.data === "string" ? payload.data : "";
if (chunk) {
await options.onOutput?.(event.event, chunk);
}
continue;
}
if (event.event === "complete") {
result = JSON.parse(event.data) as CloudflareBridgeExecuteResponse;
continue;
}
if (event.event === "error") {
const payload = JSON.parse(event.data) as { error?: unknown };
const message = typeof payload.error === "string" && payload.error.trim().length > 0
? payload.error
: "Cloudflare sandbox bridge streaming command failed.";
throw new Error(message);
}
}
if (done) break;
}
if (result) return result;
throw new Error("Cloudflare sandbox bridge streaming response ended without a completion event.");
}
export function createCloudflareBridgeClient(options: BridgeClientOptions) {
const { config } = options;
const apiPrefix = "/api/paperclip-sandbox/v1";
return {
health(extraHeaders?: BridgeClientHeaders): Promise<CloudflareBridgeHealthResponse> {
return requestJson<CloudflareBridgeHealthResponse>(config, `${apiPrefix}/health`, { method: "GET" }, extraHeaders);
},
probe(body: CloudflareBridgeProbeRequest, extraHeaders?: BridgeClientHeaders): Promise<CloudflareBridgeProbeResponse> {
return requestJson<CloudflareBridgeProbeResponse>(
config,
`${apiPrefix}/probe`,
{ method: "POST", body: JSON.stringify(body) },
extraHeaders,
);
},
acquireLease(
body: CloudflareBridgeAcquireLeaseRequest,
extraHeaders?: BridgeClientHeaders,
): Promise<CloudflareBridgeLeaseResponse> {
return requestJson<CloudflareBridgeLeaseResponse>(
config,
`${apiPrefix}/leases/acquire`,
{ method: "POST", body: JSON.stringify(body) },
extraHeaders,
);
},
resumeLease(
body: CloudflareBridgeResumeLeaseRequest,
extraHeaders?: BridgeClientHeaders,
): Promise<CloudflareBridgeLeaseResponse> {
return requestJson<CloudflareBridgeLeaseResponse>(
config,
`${apiPrefix}/leases/resume`,
{ method: "POST", body: JSON.stringify(body) },
extraHeaders,
);
},
releaseLease(
body: CloudflareBridgeReleaseLeaseRequest,
extraHeaders?: BridgeClientHeaders,
): Promise<{ ok: true }> {
return requestJson<{ ok: true }>(
config,
`${apiPrefix}/leases/release`,
{ method: "POST", body: JSON.stringify(body) },
extraHeaders,
);
},
destroyLease(providerLeaseId: string, extraHeaders?: BridgeClientHeaders): Promise<{ ok: true }> {
return requestJson<{ ok: true }>(
config,
`${apiPrefix}/leases/${encodeURIComponent(providerLeaseId)}`,
{ method: "DELETE" },
extraHeaders,
);
},
execute(
body: CloudflareBridgeExecuteRequest,
extraHeaders?: BridgeClientHeaders,
options?: BridgeExecuteOptions,
): Promise<CloudflareBridgeExecuteResponse> {
const encodedBody = encodeExecuteRequestBody(body, options);
if (typeof options?.onOutput === "function") {
return requestResponse(
config,
`${apiPrefix}/exec`,
{ method: "POST", body: encodedBody },
extraHeaders,
).then((response) => consumeExecuteEventStream(response, options));
}
return requestJson<CloudflareBridgeExecuteResponse>(
config,
`${apiPrefix}/exec`,
{ method: "POST", body: encodedBody },
extraHeaders,
);
},
};
}

View file

@ -0,0 +1,84 @@
import type { CloudflareDriverConfig } from "./types.js";
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
const DEFAULT_SLEEP_AFTER = "10m";
const DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000;
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function readTrimmedString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readBoolean(value: unknown, fallback: boolean): boolean {
return value === undefined ? fallback : value === true;
}
function readInteger(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
function isLocalBridgeHost(url: URL): boolean {
return LOCALHOST_HOSTNAMES.has(url.hostname);
}
export function parseCloudflareDriverConfig(raw: Record<string, unknown>): CloudflareDriverConfig {
return {
bridgeBaseUrl: readTrimmedString(raw.bridgeBaseUrl) ?? "",
bridgeAuthToken: readTrimmedString(raw.bridgeAuthToken) ?? "",
reuseLease: readBoolean(raw.reuseLease, false),
keepAlive: readBoolean(raw.keepAlive, false),
sleepAfter: readTrimmedString(raw.sleepAfter) ?? DEFAULT_SLEEP_AFTER,
normalizeId: readBoolean(raw.normalizeId, true),
requestedCwd: readTrimmedString(raw.requestedCwd) ?? DEFAULT_REQUESTED_CWD,
sessionStrategy: raw.sessionStrategy === "default" ? "default" : "named",
sessionId: readTrimmedString(raw.sessionId) ?? "paperclip",
timeoutMs: readInteger(raw.timeoutMs, DEFAULT_TIMEOUT_MS),
bridgeRequestTimeoutMs: readInteger(raw.bridgeRequestTimeoutMs, DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS),
previewHostname: readTrimmedString(raw.previewHostname),
};
}
export function validateCloudflareDriverConfig(config: CloudflareDriverConfig): string[] {
const errors: string[] = [];
if (!config.bridgeBaseUrl) {
errors.push("Cloudflare sandbox environments require bridgeBaseUrl.");
} else {
try {
const url = new URL(config.bridgeBaseUrl);
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalBridgeHost(url))) {
errors.push("bridgeBaseUrl must use HTTPS unless it points at localhost.");
}
} catch {
errors.push("bridgeBaseUrl must be a valid URL.");
}
}
if (!config.bridgeAuthToken) {
errors.push("Cloudflare sandbox environments require bridgeAuthToken.");
}
if (config.reuseLease && !config.keepAlive) {
errors.push("reuseLease requires keepAlive for Cloudflare sandboxes.");
}
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
errors.push("timeoutMs must be between 1 and 86400000.");
}
if (config.bridgeRequestTimeoutMs < 1 || config.bridgeRequestTimeoutMs > 86_400_000) {
errors.push("bridgeRequestTimeoutMs must be between 1 and 86400000.");
}
if (!config.requestedCwd.startsWith("/")) {
errors.push("requestedCwd must be an absolute POSIX path.");
}
if (config.sessionStrategy === "named" && config.sessionId.trim().length === 0) {
errors.push("sessionId is required when sessionStrategy is named.");
}
return errors;
}

View file

@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";

View file

@ -0,0 +1,97 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.cloudflare-sandbox-provider";
const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Cloudflare Sandbox Provider",
description:
"First-party sandbox provider plugin that provisions Cloudflare sandboxes through an operator-deployed Worker bridge.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "cloudflare",
kind: "sandbox_provider",
displayName: "Cloudflare Sandbox",
description:
"Runs Paperclip sandbox environments through a Cloudflare Worker bridge backed by the Sandbox SDK and Durable Objects.",
configSchema: {
type: "object",
properties: {
bridgeBaseUrl: {
type: "string",
format: "uri",
description: "Base URL of the operator-deployed Cloudflare Worker bridge.",
},
bridgeAuthToken: {
type: "string",
format: "secret-ref",
description:
"Bearer token used by the provider plugin when calling the Cloudflare bridge. Pasted values are stored as company secrets.",
},
reuseLease: {
type: "boolean",
default: false,
description: "Reuse a sandbox by environment ID instead of creating one per run.",
},
keepAlive: {
type: "boolean",
default: false,
description: "Prevent Cloudflare from idling the container between requests.",
},
sleepAfter: {
type: "string",
default: "10m",
description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.",
},
normalizeId: {
type: "boolean",
default: true,
description: "Lowercase and normalize sandbox IDs for operator-friendly naming.",
},
requestedCwd: {
type: "string",
default: "/workspace/paperclip",
description: "Workspace directory to create inside the sandbox lease.",
},
sessionStrategy: {
type: "string",
enum: ["named", "default"],
default: "named",
description: "Whether to run commands in a stable named session or the default session.",
},
sessionId: {
type: "string",
default: "paperclip",
description: "Named Cloudflare session ID used when sessionStrategy is named.",
},
timeoutMs: {
type: "number",
default: 300000,
description: "Default per-command timeout passed through to the bridge.",
},
bridgeRequestTimeoutMs: {
type: "number",
default: 30000,
description: "HTTP timeout for plugin-to-bridge requests.",
},
previewHostname: {
type: "string",
description: "Optional hostname reserved for future preview URL support.",
},
},
required: ["bridgeBaseUrl", "bridgeAuthToken"],
},
},
],
};
export default manifest;

View file

@ -0,0 +1,323 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "./plugin.js";
const fetchMock = vi.fn();
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function requestInitAt(index = 0): RequestInit {
return fetchMock.mock.calls[index]?.[1] as RequestInit;
}
function requestHeadersAt(index = 0): Headers {
return requestInitAt(index).headers as Headers;
}
function requestBodyAt(index = 0): Record<string, unknown> {
return JSON.parse(String(requestInitAt(index).body ?? "{}")) as Record<string, unknown>;
}
describe("Cloudflare sandbox provider plugin", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("declares the Cloudflare environment lifecycle handlers", async () => {
expect(await plugin.definition.onHealth?.()).toEqual({
status: "ok",
message: "Cloudflare sandbox provider plugin healthy",
});
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
});
it("normalizes and validates Cloudflare config", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "cloudflare",
config: {
bridgeBaseUrl: " https://bridge.example.workers.dev/ ",
bridgeAuthToken: " secret-ref://bridge-token ",
reuseLease: true,
keepAlive: true,
normalizeId: false,
requestedCwd: " /workspace/custom ",
sessionStrategy: "default",
timeoutMs: "450000.9",
bridgeRequestTimeoutMs: "40000.1",
},
});
expect(result).toEqual({
ok: true,
normalizedConfig: {
bridgeBaseUrl: "https://bridge.example.workers.dev/",
bridgeAuthToken: "secret-ref://bridge-token",
reuseLease: true,
keepAlive: true,
sleepAfter: "10m",
normalizeId: false,
requestedCwd: "/workspace/custom",
sessionStrategy: "default",
sessionId: "paperclip",
timeoutMs: 450000,
bridgeRequestTimeoutMs: 40000,
previewHostname: null,
},
});
});
it("rejects insecure or contradictory config", async () => {
await expect(plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "cloudflare",
config: {
bridgeBaseUrl: "http://bridge.example.workers.dev",
bridgeAuthToken: "secret-ref://bridge-token",
reuseLease: true,
keepAlive: false,
requestedCwd: "workspace/not-absolute",
},
})).resolves.toEqual({
ok: false,
errors: [
"bridgeBaseUrl must use HTTPS unless it points at localhost.",
"reuseLease requires keepAlive for Cloudflare sandboxes.",
"requestedCwd must be an absolute POSIX path.",
],
});
});
it("maps acquire lease responses from the bridge", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
providerLeaseId: "pc-run-1-abcd1234",
metadata: {
provider: "cloudflare",
remoteCwd: "/workspace/paperclip",
resumedLease: false,
},
}),
);
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
issueId: "issue-1",
runId: "run-1",
requestedCwd: "/workspace/paperclip",
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(lease).toEqual({
providerLeaseId: "pc-run-1-abcd1234",
metadata: {
provider: "cloudflare",
remoteCwd: "/workspace/paperclip",
resumedLease: false,
},
});
expect(fetchMock).toHaveBeenCalledWith(
"https://bridge.example.workers.dev/api/paperclip-sandbox/v1/leases/acquire",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
}),
);
expect(requestHeadersAt().get("X-Paperclip-Run-Id")).toBe("run-1");
expect(requestHeadersAt().get("X-Paperclip-Environment-Id")).toBe("env-1");
expect(requestHeadersAt().get("X-Paperclip-Issue-Id")).toBe("issue-1");
expect(requestBodyAt()).toMatchObject({
environmentId: "env-1",
runId: "run-1",
issueId: "issue-1",
requestedCwd: "/workspace/paperclip",
});
});
it("returns expired lease semantics when resume reports lost state", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
error: "sandbox_state_lost",
message: "Cloudflare sandbox state is no longer available.",
},
409,
),
);
const lease = await plugin.definition.onEnvironmentResumeLease?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "pc-env-env-1",
leaseMetadata: { remoteCwd: "/workspace/paperclip" },
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(lease).toEqual({
providerLeaseId: null,
metadata: {
provider: "cloudflare",
expired: true,
},
});
});
it("passes bridge execute results through unchanged", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "/workspace/paperclip\n",
stderr: "",
}),
);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
command: "pwd",
args: [],
cwd: "/workspace/paperclip",
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(result).toEqual({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "/workspace/paperclip\n",
stderr: "",
});
});
it("routes bridge-channel execute calls through a dedicated session", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
}),
);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
command: "sh",
args: ["-lc", "ls"],
cwd: "/workspace/paperclip",
env: {
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
KEEP_ME: "visible",
},
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
sessionStrategy: "default",
sessionId: "paperclip",
},
});
expect(requestBodyAt()).toMatchObject({
sessionStrategy: "named",
sessionId: "paperclip-bridge",
env: {
KEEP_ME: "visible",
},
});
expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL");
});
it("maps lost-lease execute errors into a deterministic command failure", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
error: "sandbox_state_lost",
message: "Cloudflare sandbox state is no longer available.",
},
409,
),
);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
command: "pwd",
args: [],
cwd: "/workspace/paperclip",
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(result).toEqual({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Cloudflare sandbox state is no longer available.\n",
});
});
it("wraps realizeWorkspace bridge failures and forwards the issue header", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
error: "command_failed",
message: "mkdir: permission denied",
},
500,
),
);
await expect(plugin.definition.onEnvironmentRealizeWorkspace?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
issueId: "issue-1",
lease: {
providerLeaseId: "pc-run-1-abcd1234",
metadata: { remoteCwd: "/workspace/paperclip" },
},
workspace: {
localPath: "/tmp/project",
metadata: {
workspaceRealizationRequest: {
issueId: "issue-1",
},
},
},
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
})).rejects.toThrow("Failed to prepare Cloudflare sandbox workspace at /workspace/paperclip: mkdir: permission denied");
expect(requestHeadersAt().get("X-Paperclip-Issue-Id")).toBe("issue-1");
});
});

View file

@ -0,0 +1,351 @@
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginLogger,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
import { CloudflareBridgeError, createCloudflareBridgeClient } from "./bridge-client.js";
import {
parseCloudflareDriverConfig,
validateCloudflareDriverConfig,
} from "./config.js";
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";
const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
const CLOUDFLARE_EXEC_STDOUT_PREFIX = "[cloudflare exec stdout]";
const CLOUDFLARE_EXEC_STDERR_PREFIX = "[cloudflare exec stderr]";
function isLostLeaseError(error: unknown): boolean {
return error instanceof CloudflareBridgeError && (error.status === 404 || error.status === 409);
}
function bridgeClientFor(rawConfig: Record<string, unknown>) {
const config = parseCloudflareDriverConfig(rawConfig);
return {
config,
client: createCloudflareBridgeClient({ config }),
};
}
function lostLeaseExecuteResult(error: CloudflareBridgeError): PluginEnvironmentExecuteResult {
return {
exitCode: 1,
timedOut: false,
signal: null,
stdout: "",
stderr:
error.message.trim().length > 0
? `${error.message}\n`
: "Cloudflare sandbox lease is no longer available.\n",
};
}
function readIssueId(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function resolveWorkspaceIssueId(params: PluginEnvironmentRealizeWorkspaceParams): string | null {
const directIssueId = readIssueId(params.issueId);
if (directIssueId) return directIssueId;
const request = params.workspace.metadata?.workspaceRealizationRequest;
if (!request || typeof request !== "object" || Array.isArray(request)) return null;
return readIssueId((request as { issueId?: unknown }).issueId);
}
function wrapWorkspacePreparationError(remoteCwd: string, error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
return new Error(`Failed to prepare Cloudflare sandbox workspace at ${remoteCwd}: ${message}`);
}
function resolveRemoteCwd(
config: ReturnType<typeof parseCloudflareDriverConfig>,
params: PluginEnvironmentRealizeWorkspaceParams,
): string {
const leaseRemoteCwd =
typeof params.lease.metadata?.remoteCwd === "string" && params.lease.metadata.remoteCwd.trim().length > 0
? params.lease.metadata.remoteCwd.trim()
: null;
return leaseRemoteCwd ?? params.workspace.remotePath ?? params.workspace.localPath ?? config.requestedCwd;
}
function resolveExecuteSession(
config: ReturnType<typeof parseCloudflareDriverConfig>,
env: Record<string, string> | undefined,
) {
if (env?.[SANDBOX_EXEC_CHANNEL_ENV] !== SANDBOX_EXEC_CHANNEL_BRIDGE) {
return {
sessionStrategy: config.sessionStrategy,
sessionId: config.sessionId,
} as const;
}
const baseSessionId = config.sessionId.trim().length > 0 ? config.sessionId : "paperclip";
return {
sessionStrategy: "named" as const,
sessionId: `${baseSessionId}-bridge`,
};
}
function sanitizeExecuteEnv(env: Record<string, string> | undefined) {
if (!env || !(SANDBOX_EXEC_CHANNEL_ENV in env)) {
return env;
}
const nextEnv = { ...env };
delete nextEnv[SANDBOX_EXEC_CHANNEL_ENV];
return nextEnv;
}
function logCloudflareExecChunk(
logger: PluginLogger | null,
stream: "stdout" | "stderr",
chunk: string,
) {
if (!logger || chunk.length === 0) return;
const lines = chunk
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => line.trim().length > 0);
for (const line of lines) {
if (stream === "stderr") {
logger.warn(`${CLOUDFLARE_EXEC_STDERR_PREFIX} ${line}`);
} else {
logger.info(`${CLOUDFLARE_EXEC_STDOUT_PREFIX} ${line}`);
}
}
}
let pluginLogger: PluginLogger | null = null;
const plugin = definePlugin({
async setup(ctx) {
pluginLogger = ctx.logger;
ctx.logger.info("Cloudflare sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Cloudflare sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseCloudflareDriverConfig(params.config);
const errors = validateCloudflareDriverConfig(config);
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
normalizedConfig: { ...config },
};
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const { config, client } = bridgeClientFor(params.config);
try {
const result = await client.probe(
{
requestedCwd: config.requestedCwd,
keepAlive: config.keepAlive,
sleepAfter: config.sleepAfter,
normalizeId: config.normalizeId,
sessionStrategy: config.sessionStrategy,
sessionId: config.sessionId,
timeoutMs: config.timeoutMs,
},
{ environmentId: params.environmentId, issueId: params.issueId },
);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
summary: "Cloudflare sandbox bridge probe failed.",
metadata: {
provider: "cloudflare",
error: message,
},
};
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const { config, client } = bridgeClientFor(params.config);
return await client.acquireLease(
{
environmentId: params.environmentId,
runId: params.runId,
issueId: params.issueId,
reuseLease: config.reuseLease,
keepAlive: config.keepAlive,
sleepAfter: config.sleepAfter,
normalizeId: config.normalizeId,
requestedCwd: params.requestedCwd?.trim() || config.requestedCwd,
sessionStrategy: config.sessionStrategy,
sessionId: config.sessionId,
timeoutMs: config.timeoutMs,
},
{ environmentId: params.environmentId, runId: params.runId, issueId: params.issueId },
);
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const { config, client } = bridgeClientFor(params.config);
try {
return await client.resumeLease(
{
providerLeaseId: params.providerLeaseId,
requestedCwd:
typeof params.leaseMetadata?.remoteCwd === "string" && params.leaseMetadata.remoteCwd.trim().length > 0
? params.leaseMetadata.remoteCwd.trim()
: config.requestedCwd,
sessionStrategy: config.sessionStrategy,
sessionId: config.sessionId,
keepAlive: config.keepAlive,
sleepAfter: config.sleepAfter,
normalizeId: config.normalizeId,
timeoutMs: config.timeoutMs,
},
{ environmentId: params.environmentId, issueId: params.issueId },
);
} catch (error) {
if (isLostLeaseError(error)) {
return {
providerLeaseId: null,
metadata: {
provider: "cloudflare",
expired: true,
},
};
}
throw error;
}
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const { config, client } = bridgeClientFor(params.config);
await client.releaseLease(
{
providerLeaseId: params.providerLeaseId,
reuseLease: config.reuseLease,
keepAlive: config.keepAlive,
},
{ environmentId: params.environmentId, issueId: params.issueId },
);
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const { client } = bridgeClientFor(params.config);
await client.destroyLease(params.providerLeaseId, {
environmentId: params.environmentId,
issueId: params.issueId,
});
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const { config, client } = bridgeClientFor(params.config);
const remoteCwd = resolveRemoteCwd(config, params);
const issueId = resolveWorkspaceIssueId(params);
if (params.lease.providerLeaseId) {
try {
await client.execute(
{
providerLeaseId: params.lease.providerLeaseId,
command: "mkdir",
args: ["-p", remoteCwd],
cwd: "/",
timeoutMs: config.timeoutMs,
sessionStrategy: config.sessionStrategy,
sessionId: config.sessionId,
},
{ environmentId: params.environmentId, issueId },
);
} catch (error) {
throw wrapWorkspacePreparationError(remoteCwd, error);
}
}
return {
cwd: remoteCwd,
metadata: {
provider: "cloudflare",
remoteCwd,
},
};
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
if (!params.lease.providerLeaseId) {
return {
exitCode: 1,
timedOut: false,
signal: null,
stdout: "",
stderr: "No provider lease ID available for execution.\n",
};
}
const { config, client } = bridgeClientFor(params.config);
const session = resolveExecuteSession(config, params.env);
try {
const streamingOptions = pluginLogger
? {
onOutput: async (stream: "stdout" | "stderr", chunk: string) => {
logCloudflareExecChunk(pluginLogger, stream, chunk);
},
}
: undefined;
return await client.execute(
{
providerLeaseId: params.lease.providerLeaseId,
command: params.command,
args: params.args,
cwd: params.cwd,
env: sanitizeExecuteEnv(params.env),
stdin: params.stdin ?? null,
timeoutMs: params.timeoutMs ?? config.timeoutMs,
sessionStrategy: session.sessionStrategy,
sessionId: session.sessionId,
},
{ environmentId: params.environmentId, issueId: params.issueId },
streamingOptions,
);
} catch (error) {
if (error instanceof CloudflareBridgeError && isLostLeaseError(error)) {
return lostLeaseExecuteResult(error);
}
throw error;
}
},
});
export default plugin;

View file

@ -0,0 +1,99 @@
export interface CloudflareDriverConfig {
bridgeBaseUrl: string;
bridgeAuthToken: string;
reuseLease: boolean;
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
requestedCwd: string;
sessionStrategy: "named" | "default";
sessionId: string;
timeoutMs: number;
bridgeRequestTimeoutMs: number;
previewHostname: string | null;
}
export interface CloudflareBridgeHealthResponse {
ok: boolean;
provider: "cloudflare";
bridgeVersion: string;
capabilities: {
reuseLease: boolean;
namedSessions: boolean;
previewUrls: boolean;
};
}
export interface CloudflareBridgeProbeRequest {
requestedCwd: string;
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
sessionId: string;
timeoutMs: number;
}
export interface CloudflareBridgeProbeResponse {
ok: boolean;
summary: string;
metadata?: Record<string, unknown>;
}
export interface CloudflareBridgeAcquireLeaseRequest {
environmentId: string;
runId: string;
issueId?: string | null;
reuseLease: boolean;
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
requestedCwd: string;
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
sessionId: string;
timeoutMs: number;
}
export interface CloudflareBridgeResumeLeaseRequest {
providerLeaseId: string;
requestedCwd: string;
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
sessionId: string;
keepAlive: boolean;
sleepAfter: string;
normalizeId: boolean;
timeoutMs: number;
}
export interface CloudflareBridgeReleaseLeaseRequest {
providerLeaseId: string;
reuseLease: boolean;
keepAlive: boolean;
}
export interface CloudflareBridgeLeaseResponse {
providerLeaseId: string;
metadata?: Record<string, unknown>;
}
export interface CloudflareBridgeExecuteRequest {
providerLeaseId: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string | null;
timeoutMs?: number;
streamOutput?: boolean;
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
sessionId: string;
}
export interface CloudflareBridgeExecuteResponse {
exitCode: number | null;
signal?: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
metadata?: Record<string, unknown>;
}

View file

@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);