Files
paperclip/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts
T
Devin Foley 486fb88a15 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>
2026-05-11 07:33:13 -07:00

352 lines
11 KiB
TypeScript

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;