mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30: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,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isAuthorizedRequest, readBearerToken } from "./auth.js";
|
||||
|
||||
describe("bridge auth", () => {
|
||||
it("extracts bearer tokens from Authorization headers", () => {
|
||||
const request = new Request("https://bridge.example.test", {
|
||||
headers: { Authorization: "Bearer secret-token" },
|
||||
});
|
||||
expect(readBearerToken(request)).toBe("secret-token");
|
||||
});
|
||||
|
||||
it("rejects mismatched tokens", async () => {
|
||||
const request = new Request("https://bridge.example.test", {
|
||||
headers: { Authorization: "Bearer wrong-token" },
|
||||
});
|
||||
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("accepts matching tokens", async () => {
|
||||
const request = new Request("https://bridge.example.test", {
|
||||
headers: { Authorization: "Bearer expected-token" },
|
||||
});
|
||||
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects requests without an Authorization header", async () => {
|
||||
const request = new Request("https://bridge.example.test");
|
||||
await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
export function readBearerToken(request: Request): string | null {
|
||||
const header = request.headers.get("Authorization");
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
// Compare two strings in constant time so an attacker can't infer the expected
|
||||
// token character-by-character via response-latency timing. We hash both sides
|
||||
// to SHA-256 first so the byte-by-byte comparison length is fixed (and doesn't
|
||||
// leak the token's length), then walk the buffers with a constant-time XOR
|
||||
// reduction. This avoids `crypto.subtle.timingSafeEqual` because that helper
|
||||
// is not portable: it exists on Cloudflare Workers but is missing from Node's
|
||||
// `crypto.subtle` (which would break unit tests). The manual XOR reduction on
|
||||
// a fixed-length hash output is the same algorithm the helper uses internally.
|
||||
async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [aHashBuf, bHashBuf] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(a)),
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(b)),
|
||||
]);
|
||||
const aBytes = new Uint8Array(aHashBuf);
|
||||
const bBytes = new Uint8Array(bHashBuf);
|
||||
if (aBytes.length !== bBytes.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < aBytes.length; i++) {
|
||||
diff |= aBytes[i] ^ bBytes[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export async function isAuthorizedRequest(
|
||||
request: Request,
|
||||
expectedToken: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!expectedToken || expectedToken.trim().length === 0) return false;
|
||||
const presented = readBearerToken(request);
|
||||
if (!presented) return false;
|
||||
return timingSafeStringEqual(presented, expectedToken);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@cloudflare/sandbox", () => ({
|
||||
getSandbox: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildLoginShellScript, executeInSandbox } from "./exec.js";
|
||||
|
||||
describe("bridge exec", () => {
|
||||
it("invokes target.exec with a single shell command string and no args option", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stdout: "claude 1.0.0\n",
|
||||
stderr: "",
|
||||
});
|
||||
const sandbox = {
|
||||
getSession: vi.fn().mockResolvedValue({ exec }),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
} as const;
|
||||
|
||||
await executeInSandbox({
|
||||
sandbox: sandbox as never,
|
||||
command: "claude",
|
||||
args: ["--version"],
|
||||
cwd: "/workspace/paperclip",
|
||||
env: { PAPERCLIP_TEST_FLAG: "1" },
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
const [commandArg, optionsArg] = exec.mock.calls[0] ?? [];
|
||||
expect(typeof commandArg).toBe("string");
|
||||
expect(commandArg).toMatch(/^sh -lc /);
|
||||
expect(optionsArg).toEqual({ cwd: "/", timeout: 12_345 });
|
||||
expect(optionsArg).not.toHaveProperty("args");
|
||||
expect(optionsArg).not.toHaveProperty("stdin");
|
||||
expect(commandArg).toContain('. /etc/profile');
|
||||
expect(commandArg).toContain("cd ");
|
||||
expect(commandArg).toContain("/workspace/paperclip");
|
||||
expect(commandArg).toContain("PAPERCLIP_TEST_FLAG");
|
||||
expect(commandArg).toContain("claude");
|
||||
expect(commandArg).toContain("--version");
|
||||
});
|
||||
|
||||
it("requests streaming callbacks when bridge output forwarding is enabled", async () => {
|
||||
const exec = 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 }),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
} as const;
|
||||
const onOutput = vi.fn();
|
||||
|
||||
await executeInSandbox({
|
||||
sandbox: sandbox as never,
|
||||
command: "echo",
|
||||
args: ["hello"],
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
timeoutMs: 5_000,
|
||||
onOutput,
|
||||
});
|
||||
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec.mock.calls[0]?.[1]).toMatchObject({
|
||||
cwd: "/",
|
||||
timeout: 5_000,
|
||||
stream: true,
|
||||
onOutput: expect.any(Function),
|
||||
});
|
||||
expect(onOutput).toHaveBeenCalledWith("stdout", "hello\n");
|
||||
});
|
||||
|
||||
it("stages stdin through a sandbox temp file and redirects from it", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteFile = vi.fn().mockResolvedValue(undefined);
|
||||
// sessionStrategy: "default" routes through the sandbox itself (no
|
||||
// getSession wrapper), so exec must live directly on the sandbox.
|
||||
const sandbox = {
|
||||
exec,
|
||||
getSession: vi.fn(),
|
||||
writeFile,
|
||||
deleteFile,
|
||||
} as const;
|
||||
|
||||
await executeInSandbox({
|
||||
sandbox: sandbox as never,
|
||||
command: "cat",
|
||||
args: [],
|
||||
sessionStrategy: "default",
|
||||
timeoutMs: 5_000,
|
||||
stdin: "payload-bytes",
|
||||
});
|
||||
|
||||
expect(writeFile).toHaveBeenCalledTimes(1);
|
||||
const [stdinPath, stdinPayload] = writeFile.mock.calls[0] ?? [];
|
||||
expect(typeof stdinPath).toBe("string");
|
||||
expect(stdinPath).toMatch(/^\/tmp\/\.paperclip-bridge-stdin-/);
|
||||
expect(stdinPayload).toBe("payload-bytes");
|
||||
|
||||
const commandArg = exec.mock.calls[0]?.[0];
|
||||
expect(commandArg).toContain(stdinPath);
|
||||
expect(commandArg).toMatch(/<\s*['"]/);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledWith(stdinPath);
|
||||
});
|
||||
|
||||
it("does not write a stdin file or redirect when stdin is empty", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
|
||||
const writeFile = vi.fn();
|
||||
const deleteFile = vi.fn();
|
||||
const sandbox = {
|
||||
getSession: vi.fn().mockResolvedValue({ exec }),
|
||||
writeFile,
|
||||
deleteFile,
|
||||
} as const;
|
||||
|
||||
await executeInSandbox({
|
||||
sandbox: sandbox as never,
|
||||
command: "pwd",
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
timeoutMs: 5_000,
|
||||
stdin: null,
|
||||
});
|
||||
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
const commandArg = exec.mock.calls[0]?.[0];
|
||||
expect(commandArg).not.toContain("<");
|
||||
});
|
||||
|
||||
it("rejects invalid environment variable keys in the login-shell wrapper", () => {
|
||||
expect(() => buildLoginShellScript({
|
||||
command: "pwd",
|
||||
args: [],
|
||||
env: { "bad-key": "1" },
|
||||
})).toThrow("Invalid sandbox environment variable key: bad-key");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
|
||||
import { shellQuote } from "./helpers.js";
|
||||
import { isTimeoutError } from "./sandboxes.js";
|
||||
import { cleanupTimedOutExecution, resolveExecutionTarget, type SessionStrategy } from "./sessions.js";
|
||||
|
||||
export interface BridgeExecuteParams {
|
||||
sandbox: CloudflareSandbox;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string | null;
|
||||
timeoutMs?: number;
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId?: string;
|
||||
onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function isValidShellEnvKey(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
if (typeof uuid === "string" && uuid.length > 0) return uuid.replace(/[^a-zA-Z0-9-]/g, "");
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function buildLoginShellScript(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdinFile?: string | null;
|
||||
}): string {
|
||||
const env = input.env ?? {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (!isValidShellEnvKey(key)) {
|
||||
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const envArgs = Object.entries(env)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
||||
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
||||
const stdinRedirect = input.stdinFile ? ` < ${shellQuote(input.stdinFile)}` : "";
|
||||
const lines = [
|
||||
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
||||
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
||||
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
||||
];
|
||||
if (input.cwd) {
|
||||
lines.push(`cd ${shellQuote(input.cwd)}`);
|
||||
}
|
||||
const execLine = envArgs.length > 0
|
||||
? `exec env ${envArgs.join(" ")} ${commandParts}${stdinRedirect}`
|
||||
: `exec ${commandParts}${stdinRedirect}`;
|
||||
lines.push(execLine);
|
||||
return lines.join(" && ");
|
||||
}
|
||||
|
||||
function coerceExecuteResult(result: {
|
||||
success?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
}) {
|
||||
return {
|
||||
exitCode:
|
||||
typeof result.exitCode === "number" || result.exitCode === null
|
||||
? result.exitCode
|
||||
: result.success === false
|
||||
? 1
|
||||
: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeInSandbox(params: BridgeExecuteParams) {
|
||||
// The @cloudflare/sandbox SDK's exec() takes a single command string and a
|
||||
// narrow option set ({ cwd, env, timeout, ... }) — it does not accept `args`
|
||||
// or `stdin`. We compose the full shell command ourselves and stage stdin
|
||||
// through a temp file in the sandbox when the caller provides one.
|
||||
const stdinPayload = typeof params.stdin === "string" && params.stdin.length > 0
|
||||
? params.stdin
|
||||
: null;
|
||||
const stdinFile = stdinPayload ? `/tmp/.paperclip-bridge-stdin-${randomToken()}` : null;
|
||||
|
||||
if (stdinFile && stdinPayload) {
|
||||
await params.sandbox.writeFile(stdinFile, stdinPayload, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
try {
|
||||
const target = await resolveExecutionTarget(params.sandbox, {
|
||||
sessionStrategy: params.sessionStrategy,
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const script = buildLoginShellScript({
|
||||
command: params.command,
|
||||
args: params.args ?? [],
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
stdinFile,
|
||||
});
|
||||
const fullCommand = `sh -lc ${shellQuote(script)}`;
|
||||
const result = await target.exec(fullCommand, {
|
||||
cwd: "/",
|
||||
timeout: params.timeoutMs,
|
||||
...(typeof params.onOutput === "function"
|
||||
? {
|
||||
stream: true,
|
||||
onOutput: params.onOutput,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return coerceExecuteResult(result);
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
await cleanupTimedOutExecution(params.sandbox, {
|
||||
sessionStrategy: params.sessionStrategy,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
stdout: typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "",
|
||||
stderr: `${error instanceof Error ? error.message : String(error)}\n`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (stdinFile) {
|
||||
await params.sandbox.deleteFile?.(stdinFile).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
export function normalizeLeaseIdPart(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-");
|
||||
}
|
||||
|
||||
export function buildLeaseSandboxId(input: {
|
||||
environmentId: string;
|
||||
runId: string;
|
||||
reuseLease: boolean;
|
||||
normalizeId: boolean;
|
||||
randomId?: string;
|
||||
}): string {
|
||||
const base = input.reuseLease
|
||||
? `pc-env-${input.environmentId}`
|
||||
: `pc-${input.runId}-${input.randomId ?? crypto.randomUUID().slice(0, 8)}`;
|
||||
return input.normalizeId ? normalizeLeaseIdPart(base) : base;
|
||||
}
|
||||
|
||||
export function buildSentinelPath(remoteCwd: string): string {
|
||||
return `${remoteCwd.replace(/\/+$/, "")}/.paperclip-lease.json`;
|
||||
}
|
||||
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
const name = (error as { name?: string } | null)?.name ?? "";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /timeout/i.test(name) || /timed out|timeout/i.test(message);
|
||||
}
|
||||
|
||||
// Single-quote `value` for safe inclusion in a `sh -c` script. Single
|
||||
// quotes inside the value are escaped via the standard `'"'"'` dance.
|
||||
// Used by both `routes.ts` and `exec.ts` — keep one copy here so updates
|
||||
// (e.g. handling additional shell special characters) stay in sync.
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Sandbox } from "@cloudflare/sandbox";
|
||||
import { handleBridgeRequest, } from "./routes.js";
|
||||
import type { BridgeEnv } from "./sandboxes.js";
|
||||
|
||||
export { Sandbox };
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: BridgeEnv): Promise<Response> {
|
||||
try {
|
||||
return await handleBridgeRequest(request, env);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "internal_error",
|
||||
message,
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
|
||||
import { isAuthorizedRequest } from "./auth.js";
|
||||
import { executeInSandbox } from "./exec.js";
|
||||
import { shellQuote } from "./helpers.js";
|
||||
import {
|
||||
buildLeaseSandboxId,
|
||||
buildSentinelPath,
|
||||
DEFAULT_REMOTE_CWD,
|
||||
DEFAULT_SESSION_ID,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
resolveSandbox,
|
||||
applySandboxKeepAlive,
|
||||
toErrorResponse,
|
||||
toJsonResponse,
|
||||
type BridgeEnv,
|
||||
} from "./sandboxes.js";
|
||||
import type { SessionStrategy } from "./sessions.js";
|
||||
|
||||
interface ProbeRequestBody {
|
||||
requestedCwd?: string;
|
||||
keepAlive?: boolean;
|
||||
sleepAfter?: string;
|
||||
normalizeId?: boolean;
|
||||
sessionStrategy?: SessionStrategy;
|
||||
sessionId?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface AcquireLeaseRequestBody extends ProbeRequestBody {
|
||||
environmentId?: string;
|
||||
runId?: string;
|
||||
issueId?: string | null;
|
||||
reuseLease?: boolean;
|
||||
}
|
||||
|
||||
interface ResumeLeaseRequestBody extends ProbeRequestBody {
|
||||
providerLeaseId?: string;
|
||||
}
|
||||
|
||||
interface ReleaseLeaseRequestBody {
|
||||
providerLeaseId?: string;
|
||||
reuseLease?: boolean;
|
||||
keepAlive?: boolean;
|
||||
}
|
||||
|
||||
interface ExecuteRequestBody {
|
||||
providerLeaseId?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string | null;
|
||||
timeoutMs?: number;
|
||||
streamOutput?: boolean;
|
||||
sessionStrategy?: SessionStrategy;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return value === undefined ? fallback : value === true;
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function readInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
||||
}
|
||||
|
||||
function readSessionStrategy(value: unknown): SessionStrategy {
|
||||
return value === "default" ? "default" : "named";
|
||||
}
|
||||
|
||||
async function readJson<T>(request: Request): Promise<T> {
|
||||
return await request.json() as T;
|
||||
}
|
||||
|
||||
function encodeSseEvent(type: string, payload: unknown): string {
|
||||
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function toSseResponse(stream: ReadableStream<Uint8Array>): Response {
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function execLeaseUtility(
|
||||
sandbox: CloudflareSandbox,
|
||||
options: {
|
||||
remoteCwd: string;
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd = "/",
|
||||
) {
|
||||
return await executeInSandbox({
|
||||
sandbox,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
timeoutMs: options.timeoutMs,
|
||||
sessionStrategy: options.sessionStrategy,
|
||||
sessionId: options.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function requireZeroExit(action: string, result: { exitCode: number | null; timedOut: boolean; stderr: string }) {
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${action} timed out: ${result.stderr.trim()}`);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`${action} failed with exit code ${result.exitCode ?? "null"}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkspace(
|
||||
sandbox: CloudflareSandbox,
|
||||
options: {
|
||||
remoteCwd: string;
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
) {
|
||||
const result = await execLeaseUtility(sandbox, options, "mkdir", ["-p", options.remoteCwd], "/");
|
||||
requireZeroExit(`ensure workspace ${options.remoteCwd}`, result);
|
||||
}
|
||||
|
||||
async function writeSentinel(
|
||||
sandbox: CloudflareSandbox,
|
||||
input: {
|
||||
providerLeaseId: string;
|
||||
remoteCwd: string;
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId: string;
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
resumedLease: boolean;
|
||||
timeoutMs: number;
|
||||
},
|
||||
) {
|
||||
const sentinelPayload = JSON.stringify({
|
||||
provider: "cloudflare",
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
remoteCwd: input.remoteCwd,
|
||||
sessionStrategy: input.sessionStrategy,
|
||||
sessionId: input.sessionId,
|
||||
keepAlive: input.keepAlive,
|
||||
sleepAfter: input.sleepAfter,
|
||||
normalizeId: input.normalizeId,
|
||||
resumedLease: input.resumedLease,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}, null, 2);
|
||||
const sentinelPath = buildSentinelPath(input.remoteCwd);
|
||||
const result = await execLeaseUtility(
|
||||
sandbox,
|
||||
input,
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
`mkdir -p ${shellQuote(input.remoteCwd)} && printf '%s\\n' ${shellQuote(sentinelPayload)} > ${shellQuote(sentinelPath)}`,
|
||||
],
|
||||
"/",
|
||||
);
|
||||
requireZeroExit(`write sentinel ${sentinelPath}`, result);
|
||||
}
|
||||
|
||||
async function verifySentinel(
|
||||
sandbox: CloudflareSandbox,
|
||||
input: {
|
||||
remoteCwd: string;
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const result = await execLeaseUtility(
|
||||
sandbox,
|
||||
input,
|
||||
"sh",
|
||||
["-c", `test -s ${shellQuote(buildSentinelPath(input.remoteCwd))}`],
|
||||
"/",
|
||||
);
|
||||
return !result.timedOut && (result.exitCode ?? 0) === 0;
|
||||
}
|
||||
|
||||
export async function handleBridgeRequest(request: Request, env: BridgeEnv): Promise<Response> {
|
||||
if (!(await isAuthorizedRequest(request, env.BRIDGE_AUTH_TOKEN))) {
|
||||
return toErrorResponse(401, "unauthorized", "Missing or invalid bridge bearer token.");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
|
||||
if (request.method === "GET" && pathname === "/api/paperclip-sandbox/v1/health") {
|
||||
return toJsonResponse({
|
||||
ok: true,
|
||||
provider: "cloudflare",
|
||||
bridgeVersion: "0.1.0",
|
||||
capabilities: {
|
||||
reuseLease: true,
|
||||
namedSessions: true,
|
||||
previewUrls: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/probe") {
|
||||
const body = await readJson<ProbeRequestBody>(request);
|
||||
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
|
||||
const keepAlive = readBoolean(body.keepAlive, false);
|
||||
const sleepAfter = readString(body.sleepAfter, "10m");
|
||||
const normalizeId = readBoolean(body.normalizeId, true);
|
||||
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
|
||||
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
|
||||
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
const sandboxId = buildLeaseSandboxId({
|
||||
environmentId: "probe",
|
||||
runId: `probe-${Date.now()}`,
|
||||
reuseLease: false,
|
||||
normalizeId,
|
||||
});
|
||||
|
||||
const sandbox = await resolveSandbox(env, sandboxId, { keepAlive, sleepAfter, normalizeId });
|
||||
await applySandboxKeepAlive(sandbox, keepAlive);
|
||||
try {
|
||||
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
|
||||
const result = await executeInSandbox({
|
||||
sandbox,
|
||||
command: "pwd",
|
||||
cwd: remoteCwd,
|
||||
timeoutMs,
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
});
|
||||
return toJsonResponse({
|
||||
ok: true,
|
||||
summary: "Connected to Cloudflare sandbox bridge.",
|
||||
metadata: {
|
||||
provider: "cloudflare",
|
||||
remoteCwd,
|
||||
namedSessions: sessionStrategy === "named",
|
||||
stdout: result.stdout,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sandbox.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/acquire") {
|
||||
const body = await readJson<AcquireLeaseRequestBody>(request);
|
||||
if (!body.environmentId || !body.runId) {
|
||||
return toErrorResponse(400, "invalid_request", "environmentId and runId are required.");
|
||||
}
|
||||
|
||||
const reuseLease = readBoolean(body.reuseLease, false);
|
||||
const keepAlive = readBoolean(body.keepAlive, false);
|
||||
const sleepAfter = readString(body.sleepAfter, "10m");
|
||||
const normalizeId = readBoolean(body.normalizeId, true);
|
||||
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
|
||||
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
|
||||
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
|
||||
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
const providerLeaseId = buildLeaseSandboxId({
|
||||
environmentId: body.environmentId,
|
||||
runId: body.runId,
|
||||
reuseLease,
|
||||
normalizeId,
|
||||
});
|
||||
const sandbox = await resolveSandbox(env, providerLeaseId, { keepAlive, sleepAfter, normalizeId });
|
||||
// Guard against orphaning a keepAlive sandbox if workspace setup throws
|
||||
// after creation: Paperclip never sees the lease ID in that case, so it
|
||||
// can't clean up. Destroy here unless this is a reuseLease handshake
|
||||
// (where the sandbox may have been created by a prior acquire and we
|
||||
// shouldn't destroy it on a transient setup failure during reattachment).
|
||||
try {
|
||||
await applySandboxKeepAlive(sandbox, keepAlive);
|
||||
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
|
||||
await writeSentinel(sandbox, {
|
||||
providerLeaseId,
|
||||
remoteCwd,
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
keepAlive,
|
||||
sleepAfter,
|
||||
normalizeId,
|
||||
resumedLease: false,
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!reuseLease) {
|
||||
await sandbox.destroy().catch(() => undefined);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return toJsonResponse({
|
||||
providerLeaseId,
|
||||
metadata: {
|
||||
provider: "cloudflare",
|
||||
remoteCwd,
|
||||
sandboxId: providerLeaseId,
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
keepAlive,
|
||||
sleepAfter,
|
||||
normalizeId,
|
||||
resumedLease: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/resume") {
|
||||
const body = await readJson<ResumeLeaseRequestBody>(request);
|
||||
if (!body.providerLeaseId) {
|
||||
return toErrorResponse(400, "invalid_request", "providerLeaseId is required.");
|
||||
}
|
||||
const keepAlive = readBoolean(body.keepAlive, false);
|
||||
const sleepAfter = readString(body.sleepAfter, "10m");
|
||||
const normalizeId = readBoolean(body.normalizeId, true);
|
||||
const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
|
||||
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
|
||||
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
|
||||
const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
const sandbox = await resolveSandbox(env, body.providerLeaseId, { keepAlive, sleepAfter, normalizeId });
|
||||
// Resume always reattaches to a providerLeaseId the operator already
|
||||
// owns, so we deliberately do NOT destroy on failure here — the operator
|
||||
// has the ID and can issue an explicit release/destroy. Calling
|
||||
// `getSandbox` is idempotent on the Sandbox SDK side (no new sandbox is
|
||||
// created), so a failed resume doesn't leak a *new* sandbox.
|
||||
await applySandboxKeepAlive(sandbox, keepAlive);
|
||||
|
||||
if (!(await verifySentinel(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs }))) {
|
||||
return toErrorResponse(409, "sandbox_state_lost", "Cloudflare sandbox state is no longer available.");
|
||||
}
|
||||
|
||||
await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
|
||||
await writeSentinel(sandbox, {
|
||||
providerLeaseId: body.providerLeaseId,
|
||||
remoteCwd,
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
keepAlive,
|
||||
sleepAfter,
|
||||
normalizeId,
|
||||
resumedLease: true,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
return toJsonResponse({
|
||||
providerLeaseId: body.providerLeaseId,
|
||||
metadata: {
|
||||
provider: "cloudflare",
|
||||
remoteCwd,
|
||||
sandboxId: body.providerLeaseId,
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
keepAlive,
|
||||
sleepAfter,
|
||||
normalizeId,
|
||||
resumedLease: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/leases/release") {
|
||||
const body = await readJson<ReleaseLeaseRequestBody>(request);
|
||||
if (!body.providerLeaseId) {
|
||||
return toJsonResponse({ ok: true });
|
||||
}
|
||||
if (readBoolean(body.reuseLease, false)) {
|
||||
return toJsonResponse({ ok: true });
|
||||
}
|
||||
const sandbox = await resolveSandbox(env, body.providerLeaseId, {
|
||||
keepAlive: readBoolean(body.keepAlive, false),
|
||||
sleepAfter: "10m",
|
||||
normalizeId: true,
|
||||
});
|
||||
await sandbox.destroy().catch(() => undefined);
|
||||
return toJsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && pathname.startsWith("/api/paperclip-sandbox/v1/leases/")) {
|
||||
const providerLeaseId = decodeURIComponent(pathname.split("/").pop() ?? "");
|
||||
if (providerLeaseId.length === 0) {
|
||||
return toErrorResponse(400, "invalid_request", "providerLeaseId path parameter is required.");
|
||||
}
|
||||
const sandbox = await resolveSandbox(env, providerLeaseId, {
|
||||
keepAlive: false,
|
||||
sleepAfter: "10m",
|
||||
normalizeId: true,
|
||||
});
|
||||
await sandbox.destroy().catch(() => undefined);
|
||||
return toJsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/api/paperclip-sandbox/v1/exec") {
|
||||
const body = await readJson<ExecuteRequestBody>(request);
|
||||
if (!body.providerLeaseId || !body.command) {
|
||||
return toErrorResponse(400, "invalid_request", "providerLeaseId and command are required.");
|
||||
}
|
||||
const sessionStrategy = readSessionStrategy(body.sessionStrategy);
|
||||
const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
|
||||
const sandbox = await resolveSandbox(env, body.providerLeaseId, {
|
||||
keepAlive: false,
|
||||
sleepAfter: "10m",
|
||||
normalizeId: true,
|
||||
});
|
||||
if (body.streamOutput === true) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
const result = await executeInSandbox({
|
||||
sandbox,
|
||||
command: body.command!,
|
||||
args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
|
||||
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
|
||||
env: body.env,
|
||||
stdin: body.stdin ?? null,
|
||||
timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
onOutput: async (streamName, data) => {
|
||||
controller.enqueue(encoder.encode(encodeSseEvent(streamName, { data })));
|
||||
},
|
||||
});
|
||||
controller.enqueue(encoder.encode(encodeSseEvent("complete", result)));
|
||||
} catch (error) {
|
||||
controller.enqueue(encoder.encode(encodeSseEvent("error", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})));
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return toSseResponse(stream);
|
||||
}
|
||||
const result = await executeInSandbox({
|
||||
sandbox,
|
||||
command: body.command,
|
||||
args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
|
||||
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
|
||||
env: body.env,
|
||||
stdin: body.stdin ?? null,
|
||||
timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
|
||||
sessionStrategy,
|
||||
sessionId,
|
||||
});
|
||||
return toJsonResponse(result);
|
||||
}
|
||||
|
||||
return toErrorResponse(404, "not_found", `No bridge route matched ${request.method} ${pathname}.`);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
|
||||
|
||||
describe("bridge sandbox helpers", () => {
|
||||
it("builds reusable lease IDs from environment IDs", () => {
|
||||
expect(buildLeaseSandboxId({
|
||||
environmentId: "Env_123",
|
||||
runId: "run-ignored",
|
||||
reuseLease: true,
|
||||
normalizeId: true,
|
||||
})).toBe("pc-env-env-123");
|
||||
});
|
||||
|
||||
it("builds ephemeral lease IDs from run IDs", () => {
|
||||
expect(buildLeaseSandboxId({
|
||||
environmentId: "env-1",
|
||||
runId: "Run_123",
|
||||
reuseLease: false,
|
||||
normalizeId: true,
|
||||
randomId: "ABCD1234",
|
||||
})).toBe("pc-run-123-abcd1234");
|
||||
});
|
||||
|
||||
it("builds the workspace sentinel path", () => {
|
||||
expect(buildSentinelPath("/workspace/paperclip/")).toBe("/workspace/paperclip/.paperclip-lease.json");
|
||||
});
|
||||
|
||||
it("detects timeout-shaped errors", () => {
|
||||
expect(isTimeoutError(new Error("command timed out after 10s"))).toBe(true);
|
||||
expect(isTimeoutError(new Error("some other error"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
|
||||
import { getSandbox } from "@cloudflare/sandbox";
|
||||
import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
|
||||
|
||||
export interface BridgeEnv {
|
||||
Sandbox: DurableObjectNamespace<CloudflareSandbox>;
|
||||
BRIDGE_AUTH_TOKEN?: string;
|
||||
}
|
||||
|
||||
export interface BridgeLeaseConfig {
|
||||
keepAlive: boolean;
|
||||
sleepAfter: string;
|
||||
normalizeId: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_REMOTE_CWD = "/workspace/paperclip";
|
||||
export const DEFAULT_SESSION_ID = "paperclip";
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
export const LEASE_SENTINEL_FILE = ".paperclip-lease.json";
|
||||
|
||||
export function toJsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toErrorResponse(status: number, error: string, message: string, details?: unknown): Response {
|
||||
return toJsonResponse({ error, message, details }, status);
|
||||
}
|
||||
|
||||
export async function resolveSandbox(
|
||||
env: BridgeEnv,
|
||||
sandboxId: string,
|
||||
config: BridgeLeaseConfig,
|
||||
): Promise<CloudflareSandbox> {
|
||||
// Pure handle resolution: the constructor accepts keepAlive/sleepAfter so the
|
||||
// sandbox is created with the right defaults on first use, but we no longer
|
||||
// call `setKeepAlive` here. That side effect now lives in
|
||||
// `applySandboxKeepAlive` and is invoked only from lease-management routes,
|
||||
// so exec calls don't accidentally overwrite the lease's keepAlive policy.
|
||||
return getSandbox(env.Sandbox, sandboxId, {
|
||||
keepAlive: config.keepAlive,
|
||||
sleepAfter: config.sleepAfter,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySandboxKeepAlive(
|
||||
sandbox: CloudflareSandbox,
|
||||
keepAlive: boolean,
|
||||
): Promise<void> {
|
||||
await sandbox.setKeepAlive(keepAlive);
|
||||
}
|
||||
|
||||
export { buildLeaseSandboxId, buildSentinelPath, isTimeoutError };
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
|
||||
import { DEFAULT_SESSION_ID } from "./sandboxes.js";
|
||||
|
||||
export type SessionStrategy = "named" | "default";
|
||||
|
||||
export interface ResolvedSession {
|
||||
exec(
|
||||
command: string,
|
||||
options?: {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string | null;
|
||||
timeout?: number;
|
||||
stream?: boolean;
|
||||
onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
|
||||
},
|
||||
): Promise<{ success?: boolean; stdout?: string; stderr?: string; exitCode?: number | null }>;
|
||||
}
|
||||
|
||||
export async function getNamedSession(
|
||||
sandbox: CloudflareSandbox,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<ResolvedSession> {
|
||||
const sessionId = options.sessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
try {
|
||||
return await sandbox.getSession(sessionId);
|
||||
} catch (err) {
|
||||
// Only fall through to `createSession` for the "session not found" case.
|
||||
// The Sandbox SDK currently surfaces missing-session as an Error whose
|
||||
// message contains "not found" / "does not exist"; any other failure
|
||||
// (quota exceeded, sandbox destroyed mid-request, malformed ID) should
|
||||
// bubble up so callers see the real cause instead of a confusing
|
||||
// secondary `createSession` error that hides the root cause.
|
||||
if (!isSessionNotFoundError(err)) throw err;
|
||||
// Create the session without pinning it to a workspace path up front.
|
||||
// Workspace preparation may be the first thing we do with the session.
|
||||
return await sandbox.createSession({
|
||||
id: sessionId,
|
||||
env: options.env,
|
||||
commandTimeoutMs: options.timeoutMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
||||
return /not\s*found|does\s*not\s*exist|no\s+such\s+session/i.test(message);
|
||||
}
|
||||
|
||||
export async function resolveExecutionTarget(
|
||||
sandbox: CloudflareSandbox,
|
||||
options: {
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<ResolvedSession | CloudflareSandbox> {
|
||||
if (options.sessionStrategy === "default") return sandbox;
|
||||
return await getNamedSession(sandbox, options);
|
||||
}
|
||||
|
||||
export async function cleanupTimedOutExecution(
|
||||
sandbox: CloudflareSandbox,
|
||||
options: {
|
||||
sessionStrategy: SessionStrategy;
|
||||
sessionId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (options.sessionStrategy === "default") {
|
||||
await sandbox.destroy().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await sandbox.deleteSession(options.sessionId?.trim() || DEFAULT_SESSION_ID).catch(() => undefined);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue