mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +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
|
|
@ -0,0 +1,92 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CloudflareDriverConfig } from "./types.js";
|
||||
import { createCloudflareBridgeClient, resolveRequestTimeoutMs } from "./bridge-client.js";
|
||||
|
||||
const baseConfig: CloudflareDriverConfig = {
|
||||
bridgeBaseUrl: "https://bridge.example.workers.dev",
|
||||
bridgeAuthToken: "secret-ref://bridge-token",
|
||||
reuseLease: false,
|
||||
keepAlive: false,
|
||||
sleepAfter: "10m",
|
||||
normalizeId: true,
|
||||
requestedCwd: "/workspace/paperclip",
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
timeoutMs: 300_000,
|
||||
bridgeRequestTimeoutMs: 30_000,
|
||||
previewHostname: null,
|
||||
};
|
||||
|
||||
describe("Cloudflare bridge client timeouts", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the configured timeout for non-exec requests", () => {
|
||||
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/probe", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ timeoutMs: 270_000 }),
|
||||
})).toBe(30_000);
|
||||
});
|
||||
|
||||
it("extends exec requests to the command timeout when needed", () => {
|
||||
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "opencode", timeoutMs: 270_000 }),
|
||||
})).toBe(270_000);
|
||||
});
|
||||
|
||||
it("falls back to the configured timeout when exec timeout is missing or smaller", () => {
|
||||
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "pwd" }),
|
||||
})).toBe(30_000);
|
||||
expect(resolveRequestTimeoutMs(baseConfig, "/api/paperclip-sandbox/v1/exec", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "pwd", timeoutMs: 5_000 }),
|
||||
})).toBe(30_000);
|
||||
});
|
||||
|
||||
it("consumes streamed exec output and returns the final result", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(
|
||||
[
|
||||
'event: stdout',
|
||||
'data: {"data":"hello\\n"}',
|
||||
"",
|
||||
'event: complete',
|
||||
'data: {"exitCode":0,"signal":null,"timedOut":false,"stdout":"hello\\n","stderr":""}',
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
},
|
||||
));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createCloudflareBridgeClient({ config: baseConfig });
|
||||
const onOutput = vi.fn();
|
||||
|
||||
const result = await client.execute(
|
||||
{
|
||||
providerLeaseId: "lease-1",
|
||||
command: "echo",
|
||||
args: ["hello"],
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
},
|
||||
{},
|
||||
{ onOutput },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
});
|
||||
expect(onOutput).toHaveBeenCalledWith("stdout", "hello\n");
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({ streamOutput: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
import type {
|
||||
CloudflareBridgeAcquireLeaseRequest,
|
||||
CloudflareBridgeExecuteRequest,
|
||||
CloudflareBridgeExecuteResponse,
|
||||
CloudflareBridgeHealthResponse,
|
||||
CloudflareBridgeLeaseResponse,
|
||||
CloudflareBridgeProbeRequest,
|
||||
CloudflareBridgeProbeResponse,
|
||||
CloudflareBridgeReleaseLeaseRequest,
|
||||
CloudflareBridgeResumeLeaseRequest,
|
||||
CloudflareDriverConfig,
|
||||
} from "./types.js";
|
||||
|
||||
interface BridgeClientHeaders {
|
||||
environmentId?: string;
|
||||
runId?: string;
|
||||
issueId?: string | null;
|
||||
}
|
||||
|
||||
interface BridgeClientOptions {
|
||||
config: CloudflareDriverConfig;
|
||||
}
|
||||
|
||||
interface BridgeExecuteOptions {
|
||||
onOutput?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface BridgeErrorBody {
|
||||
error?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export class CloudflareBridgeError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | null;
|
||||
readonly details: unknown;
|
||||
|
||||
constructor(input: { status: number; code?: string | null; message: string; details?: unknown }) {
|
||||
super(input.message);
|
||||
this.name = "CloudflareBridgeError";
|
||||
this.status = input.status;
|
||||
this.code = input.code ?? null;
|
||||
this.details = input.details;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(config: CloudflareDriverConfig, extra: BridgeClientHeaders = {}): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set("Authorization", `Bearer ${config.bridgeAuthToken}`);
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (extra.environmentId) headers.set("X-Paperclip-Environment-Id", extra.environmentId);
|
||||
if (extra.runId) headers.set("X-Paperclip-Run-Id", extra.runId);
|
||||
if (extra.issueId) headers.set("X-Paperclip-Issue-Id", extra.issueId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function encodeExecuteRequestBody(body: CloudflareBridgeExecuteRequest, options?: BridgeExecuteOptions): string {
|
||||
return JSON.stringify({
|
||||
...body,
|
||||
streamOutput: typeof options?.onOutput === "function",
|
||||
});
|
||||
}
|
||||
|
||||
function parseExecuteTimeoutMs(body: RequestInit["body"]): number | null {
|
||||
if (typeof body !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { timeoutMs?: unknown };
|
||||
const timeoutMs = Number(parsed.timeoutMs);
|
||||
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRequestTimeoutMs(
|
||||
config: CloudflareDriverConfig,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
): number {
|
||||
if (!path.endsWith("/exec")) {
|
||||
return config.bridgeRequestTimeoutMs;
|
||||
}
|
||||
const requestedTimeoutMs = parseExecuteTimeoutMs(init.body);
|
||||
return requestedTimeoutMs === null
|
||||
? config.bridgeRequestTimeoutMs
|
||||
: Math.max(config.bridgeRequestTimeoutMs, requestedTimeoutMs);
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
config: CloudflareDriverConfig,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
extraHeaders: BridgeClientHeaders = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const requestTimeoutMs = resolveRequestTimeoutMs(config, path, init);
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
const baseUrl = config.bridgeBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: buildHeaders(config, extraHeaders),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body = await parseJson(response);
|
||||
if (!response.ok) {
|
||||
const errorBody = isRecord(body) ? body as BridgeErrorBody : {};
|
||||
throw new CloudflareBridgeError({
|
||||
status: response.status,
|
||||
code: typeof errorBody.error === "string" ? errorBody.error : null,
|
||||
message:
|
||||
typeof errorBody.message === "string" && errorBody.message.trim().length > 0
|
||||
? errorBody.message
|
||||
: `Cloudflare sandbox bridge request failed with HTTP ${response.status}.`,
|
||||
details: errorBody.details,
|
||||
});
|
||||
}
|
||||
return body as T;
|
||||
} catch (error) {
|
||||
if (error instanceof CloudflareBridgeError) throw error;
|
||||
if ((error as { name?: string } | null)?.name === "AbortError") {
|
||||
throw new Error(
|
||||
`Cloudflare sandbox bridge request timed out after ${requestTimeoutMs}ms.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestResponse(
|
||||
config: CloudflareDriverConfig,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
extraHeaders: BridgeClientHeaders = {},
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const requestTimeoutMs = resolveRequestTimeoutMs(config, path, init);
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
const baseUrl = config.bridgeBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: buildHeaders(config, extraHeaders),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await parseJson(response);
|
||||
const errorBody = isRecord(body) ? body as BridgeErrorBody : {};
|
||||
throw new CloudflareBridgeError({
|
||||
status: response.status,
|
||||
code: typeof errorBody.error === "string" ? errorBody.error : null,
|
||||
message:
|
||||
typeof errorBody.message === "string" && errorBody.message.trim().length > 0
|
||||
? errorBody.message
|
||||
: `Cloudflare sandbox bridge request failed with HTTP ${response.status}.`,
|
||||
details: errorBody.details,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof CloudflareBridgeError) throw error;
|
||||
if ((error as { name?: string } | null)?.name === "AbortError") {
|
||||
throw new Error(
|
||||
`Cloudflare sandbox bridge request timed out after ${requestTimeoutMs}ms.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedSseEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function parseSseChunk(buffer: string): { events: ParsedSseEvent[]; rest: string } {
|
||||
const normalized = buffer.replace(/\r\n/g, "\n");
|
||||
const frames = normalized.split("\n\n");
|
||||
const rest = frames.pop() ?? "";
|
||||
const events: ParsedSseEvent[] = [];
|
||||
|
||||
for (const frame of frames) {
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of frame.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || "message";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
events.push({
|
||||
event,
|
||||
data: dataLines.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
async function consumeExecuteEventStream(
|
||||
response: Response,
|
||||
options: BridgeExecuteOptions,
|
||||
): Promise<CloudflareBridgeExecuteResponse> {
|
||||
if (!response.body) {
|
||||
throw new Error("Cloudflare sandbox bridge streaming response had no body.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: CloudflareBridgeExecuteResponse | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
||||
const parsed = parseSseChunk(done && buffer.length > 0 ? `${buffer}\n\n` : buffer);
|
||||
buffer = parsed.rest;
|
||||
|
||||
for (const event of parsed.events) {
|
||||
if (event.event === "stdout" || event.event === "stderr") {
|
||||
const payload = JSON.parse(event.data) as { data?: unknown };
|
||||
const chunk = typeof payload.data === "string" ? payload.data : "";
|
||||
if (chunk) {
|
||||
await options.onOutput?.(event.event, chunk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.event === "complete") {
|
||||
result = JSON.parse(event.data) as CloudflareBridgeExecuteResponse;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.event === "error") {
|
||||
const payload = JSON.parse(event.data) as { error?: unknown };
|
||||
const message = typeof payload.error === "string" && payload.error.trim().length > 0
|
||||
? payload.error
|
||||
: "Cloudflare sandbox bridge streaming command failed.";
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
if (result) return result;
|
||||
throw new Error("Cloudflare sandbox bridge streaming response ended without a completion event.");
|
||||
}
|
||||
|
||||
export function createCloudflareBridgeClient(options: BridgeClientOptions) {
|
||||
const { config } = options;
|
||||
const apiPrefix = "/api/paperclip-sandbox/v1";
|
||||
|
||||
return {
|
||||
health(extraHeaders?: BridgeClientHeaders): Promise<CloudflareBridgeHealthResponse> {
|
||||
return requestJson<CloudflareBridgeHealthResponse>(config, `${apiPrefix}/health`, { method: "GET" }, extraHeaders);
|
||||
},
|
||||
|
||||
probe(body: CloudflareBridgeProbeRequest, extraHeaders?: BridgeClientHeaders): Promise<CloudflareBridgeProbeResponse> {
|
||||
return requestJson<CloudflareBridgeProbeResponse>(
|
||||
config,
|
||||
`${apiPrefix}/probe`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
acquireLease(
|
||||
body: CloudflareBridgeAcquireLeaseRequest,
|
||||
extraHeaders?: BridgeClientHeaders,
|
||||
): Promise<CloudflareBridgeLeaseResponse> {
|
||||
return requestJson<CloudflareBridgeLeaseResponse>(
|
||||
config,
|
||||
`${apiPrefix}/leases/acquire`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
resumeLease(
|
||||
body: CloudflareBridgeResumeLeaseRequest,
|
||||
extraHeaders?: BridgeClientHeaders,
|
||||
): Promise<CloudflareBridgeLeaseResponse> {
|
||||
return requestJson<CloudflareBridgeLeaseResponse>(
|
||||
config,
|
||||
`${apiPrefix}/leases/resume`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
releaseLease(
|
||||
body: CloudflareBridgeReleaseLeaseRequest,
|
||||
extraHeaders?: BridgeClientHeaders,
|
||||
): Promise<{ ok: true }> {
|
||||
return requestJson<{ ok: true }>(
|
||||
config,
|
||||
`${apiPrefix}/leases/release`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
destroyLease(providerLeaseId: string, extraHeaders?: BridgeClientHeaders): Promise<{ ok: true }> {
|
||||
return requestJson<{ ok: true }>(
|
||||
config,
|
||||
`${apiPrefix}/leases/${encodeURIComponent(providerLeaseId)}`,
|
||||
{ method: "DELETE" },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
execute(
|
||||
body: CloudflareBridgeExecuteRequest,
|
||||
extraHeaders?: BridgeClientHeaders,
|
||||
options?: BridgeExecuteOptions,
|
||||
): Promise<CloudflareBridgeExecuteResponse> {
|
||||
const encodedBody = encodeExecuteRequestBody(body, options);
|
||||
if (typeof options?.onOutput === "function") {
|
||||
return requestResponse(
|
||||
config,
|
||||
`${apiPrefix}/exec`,
|
||||
{ method: "POST", body: encodedBody },
|
||||
extraHeaders,
|
||||
).then((response) => consumeExecuteEventStream(response, options));
|
||||
}
|
||||
return requestJson<CloudflareBridgeExecuteResponse>(
|
||||
config,
|
||||
`${apiPrefix}/exec`,
|
||||
{ method: "POST", body: encodedBody },
|
||||
extraHeaders,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
84
packages/plugins/sandbox-providers/cloudflare/src/config.ts
Normal file
84
packages/plugins/sandbox-providers/cloudflare/src/config.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { CloudflareDriverConfig } from "./types.js";
|
||||
|
||||
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
|
||||
const DEFAULT_SLEEP_AFTER = "10m";
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function readTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return value === undefined ? fallback : value === true;
|
||||
}
|
||||
|
||||
function readInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
||||
}
|
||||
|
||||
function isLocalBridgeHost(url: URL): boolean {
|
||||
return LOCALHOST_HOSTNAMES.has(url.hostname);
|
||||
}
|
||||
|
||||
export function parseCloudflareDriverConfig(raw: Record<string, unknown>): CloudflareDriverConfig {
|
||||
return {
|
||||
bridgeBaseUrl: readTrimmedString(raw.bridgeBaseUrl) ?? "",
|
||||
bridgeAuthToken: readTrimmedString(raw.bridgeAuthToken) ?? "",
|
||||
reuseLease: readBoolean(raw.reuseLease, false),
|
||||
keepAlive: readBoolean(raw.keepAlive, false),
|
||||
sleepAfter: readTrimmedString(raw.sleepAfter) ?? DEFAULT_SLEEP_AFTER,
|
||||
normalizeId: readBoolean(raw.normalizeId, true),
|
||||
requestedCwd: readTrimmedString(raw.requestedCwd) ?? DEFAULT_REQUESTED_CWD,
|
||||
sessionStrategy: raw.sessionStrategy === "default" ? "default" : "named",
|
||||
sessionId: readTrimmedString(raw.sessionId) ?? "paperclip",
|
||||
timeoutMs: readInteger(raw.timeoutMs, DEFAULT_TIMEOUT_MS),
|
||||
bridgeRequestTimeoutMs: readInteger(raw.bridgeRequestTimeoutMs, DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS),
|
||||
previewHostname: readTrimmedString(raw.previewHostname),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCloudflareDriverConfig(config: CloudflareDriverConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.bridgeBaseUrl) {
|
||||
errors.push("Cloudflare sandbox environments require bridgeBaseUrl.");
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(config.bridgeBaseUrl);
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalBridgeHost(url))) {
|
||||
errors.push("bridgeBaseUrl must use HTTPS unless it points at localhost.");
|
||||
}
|
||||
} catch {
|
||||
errors.push("bridgeBaseUrl must be a valid URL.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.bridgeAuthToken) {
|
||||
errors.push("Cloudflare sandbox environments require bridgeAuthToken.");
|
||||
}
|
||||
|
||||
if (config.reuseLease && !config.keepAlive) {
|
||||
errors.push("reuseLease requires keepAlive for Cloudflare sandboxes.");
|
||||
}
|
||||
|
||||
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
||||
errors.push("timeoutMs must be between 1 and 86400000.");
|
||||
}
|
||||
|
||||
if (config.bridgeRequestTimeoutMs < 1 || config.bridgeRequestTimeoutMs > 86_400_000) {
|
||||
errors.push("bridgeRequestTimeoutMs must be between 1 and 86400000.");
|
||||
}
|
||||
|
||||
if (!config.requestedCwd.startsWith("/")) {
|
||||
errors.push("requestedCwd must be an absolute POSIX path.");
|
||||
}
|
||||
|
||||
if (config.sessionStrategy === "named" && config.sessionId.trim().length === 0) {
|
||||
errors.push("sessionId is required when sessionStrategy is named.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.cloudflare-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Cloudflare Sandbox Provider",
|
||||
description:
|
||||
"First-party sandbox provider plugin that provisions Cloudflare sandboxes through an operator-deployed Worker bridge.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "cloudflare",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Cloudflare Sandbox",
|
||||
description:
|
||||
"Runs Paperclip sandbox environments through a Cloudflare Worker bridge backed by the Sandbox SDK and Durable Objects.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bridgeBaseUrl: {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description: "Base URL of the operator-deployed Cloudflare Worker bridge.",
|
||||
},
|
||||
bridgeAuthToken: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description:
|
||||
"Bearer token used by the provider plugin when calling the Cloudflare bridge. Pasted values are stored as company secrets.",
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Reuse a sandbox by environment ID instead of creating one per run.",
|
||||
},
|
||||
keepAlive: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Prevent Cloudflare from idling the container between requests.",
|
||||
},
|
||||
sleepAfter: {
|
||||
type: "string",
|
||||
default: "10m",
|
||||
description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.",
|
||||
},
|
||||
normalizeId: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Lowercase and normalize sandbox IDs for operator-friendly naming.",
|
||||
},
|
||||
requestedCwd: {
|
||||
type: "string",
|
||||
default: "/workspace/paperclip",
|
||||
description: "Workspace directory to create inside the sandbox lease.",
|
||||
},
|
||||
sessionStrategy: {
|
||||
type: "string",
|
||||
enum: ["named", "default"],
|
||||
default: "named",
|
||||
description: "Whether to run commands in a stable named session or the default session.",
|
||||
},
|
||||
sessionId: {
|
||||
type: "string",
|
||||
default: "paperclip",
|
||||
description: "Named Cloudflare session ID used when sessionStrategy is named.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
default: 300000,
|
||||
description: "Default per-command timeout passed through to the bridge.",
|
||||
},
|
||||
bridgeRequestTimeoutMs: {
|
||||
type: "number",
|
||||
default: 30000,
|
||||
description: "HTTP timeout for plugin-to-bridge requests.",
|
||||
},
|
||||
previewHostname: {
|
||||
type: "string",
|
||||
description: "Optional hostname reserved for future preview URL support.",
|
||||
},
|
||||
},
|
||||
required: ["bridgeBaseUrl", "bridgeAuthToken"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
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");
|
||||
});
|
||||
});
|
||||
351
packages/plugins/sandbox-providers/cloudflare/src/plugin.ts
Normal file
351
packages/plugins/sandbox-providers/cloudflare/src/plugin.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
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;
|
||||
99
packages/plugins/sandbox-providers/cloudflare/src/types.ts
Normal file
99
packages/plugins/sandbox-providers/cloudflare/src/types.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export interface CloudflareDriverConfig {
|
||||
bridgeBaseUrl: string;
|
||||
bridgeAuthToken: string;
|
||||
reuseLease: boolean;
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
requestedCwd: string;
|
||||
sessionStrategy: "named" | "default";
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
bridgeRequestTimeoutMs: number;
|
||||
previewHostname: string | null;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeHealthResponse {
|
||||
ok: boolean;
|
||||
provider: "cloudflare";
|
||||
bridgeVersion: string;
|
||||
capabilities: {
|
||||
reuseLease: boolean;
|
||||
namedSessions: boolean;
|
||||
previewUrls: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeProbeRequest {
|
||||
requestedCwd: string;
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeProbeResponse {
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeAcquireLeaseRequest {
|
||||
environmentId: string;
|
||||
runId: string;
|
||||
issueId?: string | null;
|
||||
reuseLease: boolean;
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
requestedCwd: string;
|
||||
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeResumeLeaseRequest {
|
||||
providerLeaseId: string;
|
||||
requestedCwd: string;
|
||||
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
|
||||
sessionId: string;
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeReleaseLeaseRequest {
|
||||
providerLeaseId: string;
|
||||
reuseLease: boolean;
|
||||
keepAlive: boolean;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeLeaseResponse {
|
||||
providerLeaseId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeExecuteRequest {
|
||||
providerLeaseId: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string | null;
|
||||
timeoutMs?: number;
|
||||
streamOutput?: boolean;
|
||||
sessionStrategy: CloudflareDriverConfig["sessionStrategy"];
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface CloudflareBridgeExecuteResponse {
|
||||
exitCode: number | null;
|
||||
signal?: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
Loading…
Add table
Add a link
Reference in a new issue