486fb88a15
> _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>
352 lines
11 KiB
TypeScript
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;
|