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

@ -120,6 +120,24 @@ export interface EnvironmentDriverReleaseInput {
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed">;
}
function resolvePluginSandboxRpcTimeoutMs(config: Record<string, unknown>): number | undefined {
const timeoutCandidates = [
typeof config.timeoutMs === "number" ? config.timeoutMs : undefined,
typeof config.bridgeRequestTimeoutMs === "number" ? config.bridgeRequestTimeoutMs : undefined,
]
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
if (timeoutCandidates.length === 0) {
return undefined;
}
return resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: Math.max(...timeoutCandidates),
config,
});
}
export interface EnvironmentDriverLeaseInput {
environment: Environment;
lease: EnvironmentLease;
@ -446,10 +464,12 @@ function createSandboxEnvironmentDriver(
driverKey: parsed.config.provider,
companyId: input.companyId,
environmentId: input.environment.id,
issueId: input.issueId,
config: workerConfig,
providerLeaseId: reusableLease.providerLeaseId,
leaseMetadata: reusableLease.metadata ?? undefined,
},
resolvePluginSandboxRpcTimeoutMs(workerConfig),
).then((resumed) =>
typeof resumed.providerLeaseId === "string" && resumed.providerLeaseId.length > 0
? resumed
@ -463,6 +483,7 @@ function createSandboxEnvironmentDriver(
driverKey: parsed.config.provider,
companyId: input.companyId,
environmentId: input.environment.id,
issueId: input.issueId,
config: workerConfig,
// Plugin SDK requires a string; ad-hoc test leases use a fresh
// UUID so providers that validate or persist the runId still see
@ -470,6 +491,7 @@ function createSandboxEnvironmentDriver(
runId: input.heartbeatRunId ?? randomUUID(),
workspaceMode: input.executionWorkspaceMode ?? undefined,
},
resolvePluginSandboxRpcTimeoutMs(workerConfig),
);
// Ad-hoc test leases are never publishable for reuse: storing them
@ -616,6 +638,7 @@ function createSandboxEnvironmentDriver(
driverKey: providerKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
lease: {
providerLeaseId: input.lease.providerLeaseId,
@ -623,7 +646,7 @@ function createSandboxEnvironmentDriver(
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
},
workspace: input.workspace,
});
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig)));
}
}
@ -660,6 +683,7 @@ function createSandboxEnvironmentDriver(
driverKey: providerKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: sanitizedConfig,
lease: {
providerLeaseId: input.lease.providerLeaseId,
@ -701,10 +725,11 @@ function createSandboxEnvironmentDriver(
driverKey: providerKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
providerLeaseId: input.lease.providerLeaseId,
leaseMetadata: metadata,
});
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig)));
} catch {
cleanupStatus = "failed";
}
@ -869,6 +894,7 @@ function createPluginEnvironmentDriver(
driverKey: parsed.config.driverKey,
companyId: input.companyId,
environmentId: input.environment.id,
issueId: input.issueId,
config: parsed.config.driverConfig,
runId: input.heartbeatRunId ?? randomUUID(),
workspaceMode: input.executionWorkspaceMode ?? undefined,
@ -901,6 +927,7 @@ function createPluginEnvironmentDriver(
driverKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: driverConfig,
providerLeaseId: input.lease.providerLeaseId,
leaseMetadata: input.lease.metadata ?? undefined,
@ -921,6 +948,7 @@ function createPluginEnvironmentDriver(
workerManager,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: {
pluginKey,
driverKey,
@ -941,6 +969,7 @@ function createPluginEnvironmentDriver(
workerManager,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: {
pluginKey,
driverKey,
@ -971,6 +1000,7 @@ function createPluginEnvironmentDriver(
driverKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: driverConfig,
lease: {
providerLeaseId: input.lease.providerLeaseId,
@ -1001,6 +1031,7 @@ function createPluginEnvironmentDriver(
driverKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
issueId: input.lease.issueId,
config: driverConfig,
lease: {
providerLeaseId: input.lease.providerLeaseId,