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>
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@cloudflare/sandbox", () => ({
|
|
getSandbox: vi.fn(),
|
|
}));
|
|
|
|
import { handleBridgeRequest } from "./routes.js";
|
|
import { resolveSandbox } from "./sandboxes.js";
|
|
|
|
vi.mock("./sandboxes.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./sandboxes.js")>("./sandboxes.js");
|
|
return {
|
|
...actual,
|
|
resolveSandbox: vi.fn(),
|
|
};
|
|
});
|
|
|
|
function bridgeRequest(pathname: string, body: unknown): Request {
|
|
return new Request(`https://bridge.example.test${pathname}`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer secret-token",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("bridge routes", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(resolveSandbox).mockReset();
|
|
});
|
|
|
|
it("writes lease sentinels through the named-session exec target", async () => {
|
|
const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
|
|
const sandbox = {
|
|
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
|
|
createSession: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
setKeepAlive: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
|
|
|
|
const response = await handleBridgeRequest(
|
|
bridgeRequest("/api/paperclip-sandbox/v1/leases/acquire", {
|
|
environmentId: "env-1",
|
|
runId: "run-1",
|
|
requestedCwd: "/workspace/paperclip",
|
|
sessionStrategy: "named",
|
|
sessionId: "paperclip",
|
|
}),
|
|
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
// Sentinel write must NOT use sandbox.writeFile (sandbox-level race);
|
|
// it goes through the same session as the mkdir.
|
|
expect(sandbox.writeFile).not.toHaveBeenCalled();
|
|
|
|
// Both calls use a single command string — the SDK's exec API ignores
|
|
// any `args` or `stdin` option, so the bridge folds them into the
|
|
// command line itself.
|
|
expect(sessionExec).toHaveBeenCalledTimes(2);
|
|
for (const call of sessionExec.mock.calls) {
|
|
const [commandArg, optionsArg] = call;
|
|
expect(typeof commandArg).toBe("string");
|
|
expect(commandArg).toMatch(/^sh -lc /);
|
|
expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
|
|
expect(optionsArg).not.toHaveProperty("args");
|
|
expect(optionsArg).not.toHaveProperty("stdin");
|
|
}
|
|
expect(sessionExec.mock.calls[0]?.[0]).toContain("mkdir");
|
|
expect(sessionExec.mock.calls[0]?.[0]).toContain("/workspace/paperclip");
|
|
expect(sessionExec.mock.calls[1]?.[0]).toContain("/workspace/paperclip/.paperclip-lease.json");
|
|
});
|
|
|
|
it("checks lease sentinels through the named-session exec target on resume", async () => {
|
|
const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
|
|
const sandbox = {
|
|
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
|
|
createSession: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
setKeepAlive: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
|
|
|
|
const response = await handleBridgeRequest(
|
|
bridgeRequest("/api/paperclip-sandbox/v1/leases/resume", {
|
|
providerLeaseId: "pc-run-1-abcd1234",
|
|
requestedCwd: "/workspace/paperclip",
|
|
sessionStrategy: "named",
|
|
sessionId: "paperclip",
|
|
}),
|
|
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(sandbox.readFile).not.toHaveBeenCalled();
|
|
const [commandArg, optionsArg] = sessionExec.mock.calls[0] ?? [];
|
|
expect(typeof commandArg).toBe("string");
|
|
expect(commandArg).toMatch(/^sh -lc /);
|
|
expect(commandArg).toContain("test -s");
|
|
expect(commandArg).toContain("/workspace/paperclip/.paperclip-lease.json");
|
|
expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
|
|
expect(optionsArg).not.toHaveProperty("args");
|
|
});
|
|
|
|
it("streams exec stdout and completion metadata when requested", async () => {
|
|
const sessionExec = vi.fn().mockImplementation(async (_command, options) => {
|
|
await options?.onOutput?.("stdout", "hello\n");
|
|
return { exitCode: 0, stdout: "hello\n", stderr: "" };
|
|
});
|
|
const sandbox = {
|
|
getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
|
|
createSession: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
setKeepAlive: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
|
|
|
|
const response = await handleBridgeRequest(
|
|
bridgeRequest("/api/paperclip-sandbox/v1/exec", {
|
|
providerLeaseId: "pc-run-1-abcd1234",
|
|
command: "echo",
|
|
args: ["hello"],
|
|
sessionStrategy: "named",
|
|
sessionId: "paperclip",
|
|
streamOutput: true,
|
|
}),
|
|
{ BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("Content-Type")).toContain("text/event-stream");
|
|
const body = await response.text();
|
|
expect(body).toContain("event: stdout");
|
|
expect(body).toContain("event: complete");
|
|
});
|
|
});
|