mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
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:
parent
4ad1c83b84
commit
486fb88a15
40 changed files with 3082 additions and 11 deletions
323
packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts
Normal file
323
packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue