History

Add Cloudflare Workers sandbox runtime for isolated code execution

Adds a second code execution runtime (cloudflare-worker-loader) alongside the existing local-bun (node:vm) runtime. Agent-generated code runs in Cloudflare Workers V8 isolates with full network blocking (globalOutbound: null), preventing sandbox escape and data exfiltration. Architecture: - Host Worker (executor/packages/sandbox-host) receives code via POST /v1/runs - Spawns a dynamic V8 isolate per task using the Worker Loader API - User code runs in a separate ES module from the harness, preventing IIFE escape and Response.json hijacking - Tool calls route through a ToolBridge RPC entrypoint back to Convex - Console output is buffered and streamed back in real-time Security hardening: - User code in separate module (user-code.js) — cannot access req/env/ctx - Response.json captured in globals.js before user module evaluation - Timing-safe auth token comparison - All network blocked from isolate; communication only via TOOL_BRIDGE binding Also extracts transpileForRuntime() into a shared module so both runtimes transpile TypeScript before execution.

Rhys Sullivan committed 9 months ago

Commit ca439691 parent 9f5774e13 files · +1123 −51Browse files

Unified · Split

13 files
  1. .gitignore +9 −0
  2. executor/convex/executor.ts +2 −1
  3. executor/convex/executorNode.ts +65 −22
  4. executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts +329 −0
  5. executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +141 −0
  6. executor/lib/runtimes/runtime_catalog.ts +82 −0
  7. executor/lib/runtimes/runtime_core.ts +1 −28
  8. executor/lib/runtimes/transpile.ts +38 −0
  9. executor/packages/sandbox-host/.dev.vars.example +2 −0
  10. executor/packages/sandbox-host/package.json +16 −0
  11. executor/packages/sandbox-host/src/index.ts +401 −0
  12. executor/packages/sandbox-host/tsconfig.json +17 −0
  13. executor/packages/sandbox-host/wrangler.jsonc +20 −0
.gitignore+9−0
@@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json · 38 unmodified lines
39convex_local_backend.sqlite339convex_local_backend.sqlite3
40convex_local_storage/40convex_local_storage/
41 41
42# Local OpenAPI sources service state
43sources/data/*.sqlite
44sources/data/*.sqlite-shm
45sources/data/*.sqlite-wal
46
47# Cloudflare Workers local state
48.wrangler/
49.dev.vars
50
42# Generated push env file51# Generated push env file
43executor/.env.executor-push52executor/.env.executor-push
44 53
executor/convex/executor.ts+2−1
@@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines
5import { internalMutation } from "./_generated/server";5import { internalMutation } from "./_generated/server";
6import { workspaceMutation } from "../lib/functionBuilders";6import { workspaceMutation } from "../lib/functionBuilders";
7import { actorIdForAccount } from "../lib/identity";7import { actorIdForAccount } from "../lib/identity";
8import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog";
8import type { ApprovalRecord, TaskRecord } from "../lib/types";9import type { ApprovalRecord, TaskRecord } from "../lib/types";
9 10
10const DEFAULT_TIMEOUT_MS = 300_000;11const DEFAULT_TIMEOUT_MS = 300_000;
@@ -38,7 +39,7 @@ async function createTaskRecord( · 27 unmodified lines
38 }39 }
39 40
40 const runtimeId = args.runtimeId ?? "local-bun";41 const runtimeId = args.runtimeId ?? "local-bun";
41 if (runtimeId !== "local-bun") {42 if (!isKnownRuntimeId(runtimeId)) {
42 throw new Error(`Unsupported runtime: ${runtimeId}`);43 throw new Error(`Unsupported runtime: ${runtimeId}`);
43 }44 }
44 45
executor/convex/executorNode.ts+65−22
@@ -9,6 +9,13 @@ import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_ · 8 unmodified lines
9import { resolveCredentialPayload } from "../lib/credential_providers";9import { resolveCredentialPayload } from "../lib/credential_providers";
10import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants";10import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants";
11import { actorIdForAccount } from "../lib/identity";11import { actorIdForAccount } from "../lib/identity";
12import { runCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime";
13import {
14 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
15 isCloudflareWorkerLoaderConfigured,
16 isKnownRuntimeId,
17 LOCAL_BUN_RUNTIME_ID,
18} from "../lib/runtimes/runtime_catalog";
12import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";19import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";
13import { createDiscoverTool } from "../lib/tool_discovery";20import { createDiscoverTool } from "../lib/tool_discovery";
14import {21import {
@@ -1089,7 +1096,7 @@ export const runTask = internalAction({ · 1074 unmodified lines
1089 return null;1096 return null;
1090 }1097 }
1091 1098
1092 if (task.runtimeId !== "local-bun") {1099 if (!isKnownRuntimeId(task.runtimeId)) {
1093 const failed = await ctx.runMutation(internal.database.markTaskFinished, {1100 const failed = await ctx.runMutation(internal.database.markTaskFinished, {
1094 taskId: args.taskId,1101 taskId: args.taskId,
1095 status: "failed",1102 status: "failed",
@@ -1108,6 +1115,25 @@ export const runTask = internalAction({ · 12 unmodified lines
1108 return null;1115 return null;
1109 }1116 }
1110 1117
1118 if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {
1119 const failed = await ctx.runMutation(internal.database.markTaskFinished, {
1120 taskId: args.taskId,
1121 status: "failed",
1122 stdout: "",
1123 stderr: "",
1124 error: `Runtime is not configured: ${task.runtimeId}`,
1125 });
1126
1127 if (failed) {
1128 await publish(ctx, args.taskId, "task", "task.failed", {
1129 taskId: args.taskId,
1130 status: failed.status,
1131 error: failed.error,
1132 });
1133 }
1134 return null;
1135 }
1136
1111 try {1137 try {
1112 const running = (await ctx.runMutation(internal.database.markTaskRunning, {1138 const running = (await ctx.runMutation(internal.database.markTaskRunning, {
1113 taskId: args.taskId,1139 taskId: args.taskId,
@@ -1122,27 +1148,44 @@ export const runTask = internalAction({ · 8 unmodified lines
1122 startedAt: running.startedAt,1148 startedAt: running.startedAt,
1123 });1149 });
1124 1150
1125 const adapter = new InProcessExecutionAdapter({1151 const runtimeResult =
1126 runId: args.taskId,1152 running.runtimeId === LOCAL_BUN_RUNTIME_ID
1127 invokeTool: async (call) => await invokeTool(ctx, running, call),1153 ? await (async () => {
1128 emitOutput: async (event) => {1154 const adapter = new InProcessExecutionAdapter({
1129 await ctx.runMutation(internal.executor.appendRuntimeOutput, {1155 runId: args.taskId,
1130 runId: event.runId,1156 invokeTool: async (call) => await invokeTool(ctx, running, call),
1131 stream: event.stream,1157 emitOutput: async (event) => {
1132 line: event.line,1158 await ctx.runMutation(internal.executor.appendRuntimeOutput, {
1133 timestamp: event.timestamp,1159 runId: event.runId,
1134 });1160 stream: event.stream,
1135 },1161 line: event.line,
1136 });1162 timestamp: event.timestamp,
1137 1163 });
1138 const runtimeResult = await runCodeWithAdapter(1164 },
1139 {1165 });
1140 taskId: args.taskId,1166
1141 code: running.code,1167 return await runCodeWithAdapter(
1142 timeoutMs: running.timeoutMs,1168 {
1143 },1169 taskId: args.taskId,
1144 adapter,1170 code: running.code,
1145 );1171 timeoutMs: running.timeoutMs,
1172 },
1173 adapter,
1174 );
1175 })()
1176 : running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID
1177 ? await runCodeWithCloudflareWorkerLoader({
1178 taskId: args.taskId,
1179 code: running.code,
1180 timeoutMs: running.timeoutMs,
1181 })
1182 : {
1183 status: "failed" as const,
1184 stdout: "",
1185 stderr: "",
1186 error: `Runtime not found: ${running.runtimeId}`,
1187 durationMs: 0,
1188 };
1146 1189
1147 const finished = await ctx.runMutation(internal.database.markTaskFinished, {1190 const finished = await ctx.runMutation(internal.database.markTaskFinished, {
1148 taskId: args.taskId,1191 taskId: args.taskId,
executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts+329−0
@@ -0,0 +1,329 @@
1import { expect, test, describe, beforeAll, afterAll } from "bun:test";
2import { runCodeWithCloudflareWorkerLoader } from "./cloudflare_worker_loader_runtime";
3
4// ── Fake host worker ─────────────────────────────────────────────────────────
5//
6// This test spins up a local HTTP server that mimics the CF host worker's
7// /v1/runs endpoint. It validates the request shape and returns a result.
8// It also acts as the callback server for /internal/runs/:id/tool-call.
9
10let fakeHostServer: ReturnType<typeof Bun.serve>;
11let fakeCallbackServer: ReturnType<typeof Bun.serve>;
12
13const AUTH_TOKEN = "test-sandbox-token";
14const CALLBACK_TOKEN = "test-callback-token";
15
16const toolResponses = new Map<string, unknown>();
17const capturedOutputs: Array<{ stream: string; line: string }> = [];
18
19beforeAll(() => {
20 // Callback server — mimics the Convex /internal/runs/:id/tool-call endpoint
21 fakeCallbackServer = Bun.serve({
22 port: 0,
23 fetch: async (req) => {
24 const url = new URL(req.url);
25
26 // Verify auth
27 const auth = req.headers.get("authorization");
28 if (auth !== `Bearer ${CALLBACK_TOKEN}`) {
29 return Response.json({ error: "Unauthorized" }, { status: 401 });
30 }
31
32 // Tool call
33 if (url.pathname.endsWith("/tool-call")) {
34 const body = (await req.json()) as { toolPath: string; input: unknown };
35 const response = toolResponses.get(body.toolPath);
36 if (response !== undefined) {
37 return Response.json({ ok: true, value: response });
38 }
39 return Response.json({ ok: false, error: `Unknown tool: ${body.toolPath}` });
40 }
41
42 // Output
43 if (url.pathname.endsWith("/output")) {
44 const body = (await req.json()) as { stream: string; line: string };
45 capturedOutputs.push({ stream: body.stream, line: body.line });
46 return Response.json({ ok: true });
47 }
48
49 return Response.json({ error: "Not found" }, { status: 404 });
50 },
51 });
52
53 // Host server — mimics the CF sandbox host worker's /v1/runs endpoint
54 fakeHostServer = Bun.serve({
55 port: 0,
56 fetch: async (req) => {
57 const url = new URL(req.url);
58
59 if (url.pathname === "/health") {
60 return Response.json({ ok: true });
61 }
62
63 if (url.pathname !== "/v1/runs" || req.method !== "POST") {
64 return Response.json({ error: "Not found" }, { status: 404 });
65 }
66
67 const auth = req.headers.get("authorization");
68 if (auth !== `Bearer ${AUTH_TOKEN}`) {
69 return Response.json({ error: "Unauthorized" }, { status: 401 });
70 }
71
72 const body = (await req.json()) as {
73 taskId: string;
74 code: string;
75 timeoutMs: number;
76 callback: { baseUrl: string; authToken: string };
77 };
78
79 // Simulate the sandbox running the code in a very simplified way.
80 // In production, the CF Worker Loader would spawn an isolate.
81 // Here we just use eval-like logic to test the protocol.
82 const stdout: string[] = [];
83 const stderr: string[] = [];
84
85 try {
86 // Simulate console and tools
87 const consoleProxy = {
88 log: (...args: unknown[]) => stdout.push(args.map(String).join(" ")),
89 info: (...args: unknown[]) => stdout.push(args.map(String).join(" ")),
90 warn: (...args: unknown[]) => stderr.push(args.map(String).join(" ")),
91 error: (...args: unknown[]) => stderr.push(args.map(String).join(" ")),
92 };
93
94 // For tool calls, call back to the callback server
95 const callTool = async (toolPath: string, input: unknown) => {
96 const callbackUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/tool-call`;
97 const resp = await fetch(callbackUrl, {
98 method: "POST",
99 headers: {
100 "Content-Type": "application/json",
101 Authorization: `Bearer ${body.callback.authToken}`,
102 },
103 body: JSON.stringify({ callId: `call_${crypto.randomUUID()}`, toolPath, input }),
104 });
105 const result = (await resp.json()) as { ok: boolean; value?: unknown; error?: string };
106 if (!result.ok) throw new Error(result.error ?? "Tool call failed");
107 return result.value;
108 };
109
110 // Emit output back to callback server
111 const emitLine = async (stream: string, line: string) => {
112 const outputUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/output`;
113 await fetch(outputUrl, {
114 method: "POST",
115 headers: {
116 "Content-Type": "application/json",
117 Authorization: `Bearer ${body.callback.authToken}`,
118 },
119 body: JSON.stringify({ stream, line, timestamp: Date.now() }),
120 }).catch(() => {});
121 };
122
123 // Create a minimal tools proxy
124 const createProxy = (path: string[] = []): unknown => {
125 const callable = () => {};
126 return new Proxy(callable, {
127 get(_target, prop) {
128 if (prop === "then") return undefined;
129 if (typeof prop !== "string") return undefined;
130 return createProxy([...path, prop]);
131 },
132 async apply(_target, _thisArg, args) {
133 return callTool(path.join("."), args[0]);
134 },
135 });
136 };
137
138 const tools = createProxy();
139 const fn = new Function(
140 "tools", "console", "setTimeout", "clearTimeout",
141 `"use strict"; return (async () => {\n${body.code}\n})();`,
142 );
143 const value = await fn(tools, consoleProxy, setTimeout, clearTimeout);
144
145 if (value !== undefined) {
146 stdout.push(`result: ${JSON.stringify(value)}`);
147 }
148
149 // Stream output to callback
150 for (const line of stdout) await emitLine("stdout", line);
151 for (const line of stderr) await emitLine("stderr", line);
152
153 return Response.json({
154 status: "completed",
155 stdout: stdout.join("\n"),
156 stderr: stderr.join("\n"),
157 exitCode: 0,
158 });
159 } catch (error) {
160 const message = error instanceof Error ? error.message : String(error);
161 return Response.json({
162 status: "failed",
163 stdout: stdout.join("\n"),
164 stderr: stderr.join("\n"),
165 error: message,
166 });
167 }
168 },
169 });
170
171 // Set environment variables for the runtime
172 process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`;
173 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN;
174 process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`;
175 process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN;
176 process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000";
177});
178
179afterAll(() => {
180 fakeHostServer?.stop(true);
181 fakeCallbackServer?.stop(true);
182 delete process.env.CLOUDFLARE_SANDBOX_RUN_URL;
183 delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;
184 delete process.env.CONVEX_SITE_URL;
185 delete process.env.EXECUTOR_INTERNAL_TOKEN;
186 delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS;
187});
188
189describe("cloudflare worker loader runtime", () => {
190 test("executes simple code and returns stdout", async () => {
191 const result = await runCodeWithCloudflareWorkerLoader({
192 taskId: `task_${crypto.randomUUID()}`,
193 code: `console.log("hello from cf sandbox");`,
194 timeoutMs: 5_000,
195 });
196
197 expect(result.status).toBe("completed");
198 expect(result.stdout).toContain("hello from cf sandbox");
199 expect(result.durationMs).toBeGreaterThan(0);
200 });
201
202 test("returns a value", async () => {
203 const result = await runCodeWithCloudflareWorkerLoader({
204 taskId: `task_${crypto.randomUUID()}`,
205 code: `return 42;`,
206 timeoutMs: 5_000,
207 });
208
209 expect(result.status).toBe("completed");
210 expect(result.stdout).toContain("42");
211 });
212
213 test("captures errors as failed status", async () => {
214 const result = await runCodeWithCloudflareWorkerLoader({
215 taskId: `task_${crypto.randomUUID()}`,
216 code: `throw new Error("boom");`,
217 timeoutMs: 5_000,
218 });
219
220 expect(result.status).toBe("failed");
221 expect(result.error).toContain("boom");
222 });
223
224 test("calls tools via callback server", async () => {
225 toolResponses.set("math.add", { sum: 7 });
226
227 const result = await runCodeWithCloudflareWorkerLoader({
228 taskId: `task_${crypto.randomUUID()}`,
229 code: `
230 const out = await tools.math.add({ a: 3, b: 4 });
231 console.log("sum:", out.sum);
232 `,
233 timeoutMs: 5_000,
234 });
235
236 expect(result.status).toBe("completed");
237 expect(result.stdout).toContain("sum: 7");
238
239 toolResponses.delete("math.add");
240 });
241
242 test("streams output to callback server", async () => {
243 capturedOutputs.length = 0;
244
245 const result = await runCodeWithCloudflareWorkerLoader({
246 taskId: `task_${crypto.randomUUID()}`,
247 code: `console.log("streamed line");`,
248 timeoutMs: 5_000,
249 });
250
251 expect(result.status).toBe("completed");
252 expect(capturedOutputs.some((o) => o.line === "streamed line" && o.stream === "stdout")).toBe(
253 true,
254 );
255 });
256
257 test("transpiles TypeScript code before sending to sandbox", async () => {
258 const result = await runCodeWithCloudflareWorkerLoader({
259 taskId: `task_${crypto.randomUUID()}`,
260 code: `
261 interface User {
262 name: string;
263 age: number;
264 }
265 const user: User = { name: "Alice", age: 30 };
266 const greet = (u: User): string => \`Hello \${u.name}, age \${u.age}\`;
267 console.log(greet(user));
268 `,
269 timeoutMs: 5_000,
270 });
271
272 expect(result.status).toBe("completed");
273 expect(result.stdout).toContain("Hello Alice, age 30");
274 });
275
276 test("reports TypeScript syntax errors clearly", async () => {
277 const result = await runCodeWithCloudflareWorkerLoader({
278 taskId: `task_${crypto.randomUUID()}`,
279 // Unterminated type syntax that TS transpiler will reject
280 code: `const x: = 5;`,
281 timeoutMs: 5_000,
282 });
283
284 expect(result.status).toBe("failed");
285 expect(result.error).toContain("TypeScript transpile error");
286 });
287
288 test("handles unknown tool gracefully", async () => {
289 const result = await runCodeWithCloudflareWorkerLoader({
290 taskId: `task_${crypto.randomUUID()}`,
291 code: `await tools.nonexistent.thing({});`,
292 timeoutMs: 5_000,
293 });
294
295 expect(result.status).toBe("failed");
296 expect(result.error).toContain("nonexistent.thing");
297 });
298});
299
300describe("runtime catalog", () => {
301 test("isKnownRuntimeId recognizes both runtimes", async () => {
302 const {
303 isKnownRuntimeId,
304 LOCAL_BUN_RUNTIME_ID,
305 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
306 } = await import("./runtime_catalog");
307
308 expect(isKnownRuntimeId(LOCAL_BUN_RUNTIME_ID)).toBe(true);
309 expect(isKnownRuntimeId(CLOUDFLARE_WORKER_LOADER_RUNTIME_ID)).toBe(true);
310 expect(isKnownRuntimeId("unknown-runtime")).toBe(false);
311 });
312
313 test("isCloudflareWorkerLoaderConfigured checks env vars", async () => {
314 const { isCloudflareWorkerLoaderConfigured } = await import("./runtime_catalog");
315 // Env vars are set in beforeAll
316 expect(isCloudflareWorkerLoaderConfigured()).toBe(true);
317 });
318
319 test("getCloudflareWorkerLoaderConfig reads env vars", async () => {
320 const { getCloudflareWorkerLoaderConfig } = await import("./runtime_catalog");
321 const config = getCloudflareWorkerLoaderConfig();
322
323 expect(config.runUrl).toContain("/v1/runs");
324 expect(config.authToken).toBe(AUTH_TOKEN);
325 expect(config.callbackBaseUrl).toContain(String(fakeCallbackServer.port));
326 expect(config.callbackAuthToken).toBe(CALLBACK_TOKEN);
327 expect(config.requestTimeoutMs).toBe(10_000);
328 });
329});
executor/lib/runtimes/cloudflare_worker_loader_runtime.ts+141−0
@@ -0,0 +1,141 @@
1"use node";
2
3import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types";
4import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog";
5import { transpileForRuntime } from "./transpile";
6
7/**
8 * Run agent-generated code via a Cloudflare Worker that uses the Dynamic
9 * Worker Loader API to spawn a sandboxed isolate.
10 *
11 * ## Architecture
12 *
13 * 1. This function (running inside a Convex action) POSTs the code + config to
14 * a **host Worker** deployed on Cloudflare.
15 *
16 * 2. The host Worker uses `env.LOADER.get(id, callback)` to create a dynamic
17 * isolate containing the user code.
18 *
19 * 3. The dynamic isolate's `tools` proxy calls are intercepted by a
20 * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings),
21 * which in turn calls back to the Convex `/internal/runs/{runId}/tool-call`
22 * HTTP endpoint to resolve the tool.
23 *
24 * 4. Console output from the isolate is similarly relayed back to
25 * `/internal/runs/{runId}/output`.
26 *
27 * 5. When execution completes, the host Worker returns the result as JSON and
28 * this function maps it to a `SandboxExecutionResult`.
29 *
30 * ## Callback authentication
31 *
32 * The host Worker authenticates its callbacks using the same
33 * `EXECUTOR_INTERNAL_TOKEN` bearer token that the Convex HTTP API expects.
34 */
35export async function runCodeWithCloudflareWorkerLoader(
36 request: SandboxExecutionRequest,
37): Promise<SandboxExecutionResult> {
38 const config = getCloudflareWorkerLoaderConfig();
39 const startedAt = Date.now();
40
41 const controller = new AbortController();
42 const timeout = setTimeout(
43 () => controller.abort(),
44 config.requestTimeoutMs,
45 );
46
47 try {
48 // Transpile TS → JS on the Convex side before sending to the CF isolate.
49 // The dynamic isolate runs the code as plain JS (harness.js), so any
50 // TypeScript syntax must be stripped first.
51 const code = await transpileForRuntime(request.code);
52
53 const response = await fetch(config.runUrl, {
54 method: "POST",
55 headers: {
56 "Content-Type": "application/json",
57 Authorization: `Bearer ${config.authToken}`,
58 },
59 body: JSON.stringify({
60 taskId: request.taskId,
61 code,
62 timeoutMs: request.timeoutMs,
63 // The host Worker needs these to call back to Convex for tool
64 // invocations and output streaming.
65 callback: {
66 baseUrl: config.callbackBaseUrl,
67 authToken: config.callbackAuthToken,
68 },
69 }),
70 signal: controller.signal,
71 });
72
73 if (!response.ok) {
74 const text = await response.text().catch(() => response.statusText);
75 return {
76 status: "failed",
77 stdout: "",
78 stderr: text,
79 error: `Cloudflare sandbox returned ${response.status}: ${text}`,
80 durationMs: Date.now() - startedAt,
81 };
82 }
83
84 const result = (await response.json()) as {
85 status?: string;
86 stdout?: string;
87 stderr?: string;
88 error?: string;
89 exitCode?: number;
90 };
91
92 const status = mapStatus(result.status);
93
94 return {
95 status,
96 stdout: result.stdout ?? "",
97 stderr: result.stderr ?? "",
98 exitCode: result.exitCode,
99 error: result.error,
100 durationMs: Date.now() - startedAt,
101 };
102 } catch (error) {
103 const message = error instanceof Error ? error.message : String(error);
104 const isAbort = error instanceof DOMException && error.name === "AbortError";
105
106 if (isAbort) {
107 return {
108 status: "timed_out",
109 stdout: "",
110 stderr: "",
111 error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`,
112 durationMs: Date.now() - startedAt,
113 };
114 }
115
116 return {
117 status: "failed",
118 stdout: "",
119 stderr: "",
120 error: `Cloudflare sandbox request failed: ${message}`,
121 durationMs: Date.now() - startedAt,
122 };
123 } finally {
124 clearTimeout(timeout);
125 }
126}
127
128function mapStatus(
129 raw: string | undefined,
130): SandboxExecutionResult["status"] {
131 switch (raw) {
132 case "completed":
133 return "completed";
134 case "timed_out":
135 return "timed_out";
136 case "denied":
137 return "denied";
138 default:
139 return "failed";
140 }
141}
executor/lib/runtimes/runtime_catalog.ts+82−0
@@ -0,0 +1,82 @@
1// ── Runtime ID constants ──────────────────────────────────────────────────────
2
3export const LOCAL_BUN_RUNTIME_ID = "local-bun";
4export const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader";
5
6const KNOWN_RUNTIME_IDS = new Set([
7 LOCAL_BUN_RUNTIME_ID,
8 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
9]);
10
11export function isKnownRuntimeId(runtimeId: string): boolean {
12 return KNOWN_RUNTIME_IDS.has(runtimeId);
13}
14
15// ── Cloudflare Worker Loader config ──────────────────────────────────────────
16
17export interface CloudflareWorkerLoaderConfig {
18 /** The URL of the CF host worker's /v1/runs endpoint. */
19 runUrl: string;
20 /** Shared-secret bearer token for authenticating with the host worker. */
21 authToken: string;
22 /** HTTP request timeout in ms (how long we wait for the host worker to respond). */
23 requestTimeoutMs: number;
24 /** The Convex site URL that the CF host worker calls back to for tool invocations. */
25 callbackBaseUrl: string;
26 /** Internal auth token that the CF host worker uses when calling back. */
27 callbackAuthToken: string;
28}
29
30/**
31 * Returns true if all required env vars for the Cloudflare Worker Loader
32 * runtime are present.
33 */
34export function isCloudflareWorkerLoaderConfigured(): boolean {
35 return Boolean(
36 process.env.CLOUDFLARE_SANDBOX_RUN_URL
37 && process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN,
38 );
39}
40
41/**
42 * Reads Cloudflare Worker Loader config from environment variables.
43 * Throws if required vars are missing.
44 */
45export function getCloudflareWorkerLoaderConfig(): CloudflareWorkerLoaderConfig {
46 const runUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL;
47 const authToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;
48
49 if (!runUrl || !authToken) {
50 throw new Error(
51 "Cloudflare Worker Loader runtime requires CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN",
52 );
53 }
54
55 // The callback URL is the Convex site URL where the internal runs API lives.
56 // The CF host worker will POST to {callbackBaseUrl}/internal/runs/{runId}/tool-call
57 const callbackBaseUrl = process.env.CONVEX_SITE_URL ?? process.env.CONVEX_URL;
58 if (!callbackBaseUrl) {
59 throw new Error(
60 "Cloudflare Worker Loader runtime requires CONVEX_SITE_URL or CONVEX_URL for tool-call callbacks",
61 );
62 }
63
64 const callbackAuthToken = process.env.EXECUTOR_INTERNAL_TOKEN;
65 if (!callbackAuthToken) {
66 throw new Error(
67 "Cloudflare Worker Loader runtime requires EXECUTOR_INTERNAL_TOKEN for authenticated tool-call callbacks",
68 );
69 }
70
71 const requestTimeoutMs = Number(
72 process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS ?? "90000",
73 );
74
75 return {
76 runUrl,
77 authToken,
78 requestTimeoutMs,
79 callbackBaseUrl,
80 callbackAuthToken,
81 };
82}
executor/lib/runtimes/runtime_core.ts+1−28
@@ -11,34 +11,7 @@ import type { · 10 unmodified lines
11 SandboxExecutionRequest,11 SandboxExecutionRequest,
12 SandboxExecutionResult,12 SandboxExecutionResult,
13} from "../types";13} from "../types";
14 14import { transpileForRuntime } from "./transpile";
15async function transpileForRuntime(code: string): Promise<string> {
16 let ts: typeof import("typescript");
17 try {
18 ts = require("typescript");
19 } catch {
20 return code;
21 }
22
23 const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext;
24 const moduleKind = ts.ModuleKind?.ESNext;
25
26 const result = ts.transpileModule(code, {
27 compilerOptions: {
28 ...(target !== undefined ? { target } : {}),
29 ...(moduleKind !== undefined ? { module: moduleKind } : {}),
30 },
31 reportDiagnostics: true,
32 });
33
34 if (result.diagnostics && result.diagnostics.length > 0) {
35 const first = result.diagnostics[0];
36 const message = ts.flattenDiagnosticMessageText(first.messageText, "\n");
37 throw new Error(`TypeScript transpile error: ${message}`);
38 }
39
40 return result.outputText || code;
41}
42 15
43function formatArgs(args: unknown[]): string {16function formatArgs(args: unknown[]): string {
44 return args17 return args
executor/lib/runtimes/transpile.ts+38−0
@@ -0,0 +1,38 @@
1"use node";
2
3/**
4 * Transpile TypeScript code to JavaScript using the `typescript` module.
5 *
6 * If the `typescript` module is not available, the code is returned as-is
7 * (graceful fallback for environments where TS isn't installed).
8 *
9 * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm,
10 * Cloudflare Workers isolates, etc.) without further downlevelling.
11 */
12export async function transpileForRuntime(code: string): Promise<string> {
13 let ts: typeof import("typescript");
14 try {
15 ts = require("typescript");
16 } catch {
17 return code;
18 }
19
20 const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext;
21 const moduleKind = ts.ModuleKind?.ESNext;
22
23 const result = ts.transpileModule(code, {
24 compilerOptions: {
25 ...(target !== undefined ? { target } : {}),
26 ...(moduleKind !== undefined ? { module: moduleKind } : {}),
27 },
28 reportDiagnostics: true,
29 });
30
31 if (result.diagnostics && result.diagnostics.length > 0) {
32 const first = result.diagnostics[0];
33 const message = ts.flattenDiagnosticMessageText(first.messageText, "\n");
34 throw new Error(`TypeScript transpile error: ${message}`);
35 }
36
37 return result.outputText || code;
38}
executor/packages/sandbox-host/.dev.vars.example+2−0
@@ -0,0 +1,2 @@
1# Shared-secret bearer token — must match CLOUDFLARE_SANDBOX_AUTH_TOKEN on the executor side.
2AUTH_TOKEN=dev-sandbox-token
executor/packages/sandbox-host/package.json+16−0
@@ -0,0 +1,16 @@
1{
2 "name": "@executor/sandbox-host",
3 "version": "0.1.0",
4 "private": true,
5 "type": "module",
6 "scripts": {
7 "dev": "wrangler dev --port 8787",
8 "deploy": "wrangler deploy",
9 "typecheck": "tsc --noEmit"
10 },
11 "devDependencies": {
12 "@cloudflare/workers-types": "^4.20250214.0",
13 "typescript": "^5.9.3",
14 "wrangler": "^4.0.0"
15 }
16}
executor/packages/sandbox-host/src/index.ts+401−0
@@ -0,0 +1,401 @@
1/**
2 * Executor Sandbox Host Worker
3 *
4 * This Cloudflare Worker uses the Dynamic Worker Loader API to run
5 * agent-generated code in sandboxed isolates. It exposes a single HTTP
6 * endpoint (`POST /v1/runs`) that the executor's Convex action calls.
7 *
8 * ## How it works
9 *
10 * 1. Receives a run request with `{ taskId, code, timeoutMs, callback }`.
11 *
12 * 2. Uses `env.LOADER.get(id, () => WorkerCode)` to spawn a dynamic isolate
13 * containing the user's code.
14 *
15 * 3. The isolate's network access is fully blocked (`globalOutbound: null`).
16 * Instead, tool calls are routed through a `ToolBridge` entrypoint class
17 * (passed as a loopback service binding via `ctx.exports`) which calls back
18 * to the Convex HTTP API to resolve them.
19 *
20 * 4. Console output is buffered in the harness and returned in the response.
21 * Output lines are also streamed back to Convex in real-time via the
22 * ToolBridge binding.
23 *
24 * 5. The result (status, stdout, stderr, error) is returned as JSON.
25 *
26 * ## Code isolation
27 *
28 * User code is placed in a **separate JS module** (`user-code.js`) that
29 * exports a single `run(tools, console)` async function. The harness module
30 * (`harness.js`) imports and calls this function, passing controlled `tools`
31 * and `console` proxies. Because the user code is in a different module, it
32 * cannot access the harness's `fetch` handler scope, `req`, `env`, `ctx`,
33 * or `Response` — preventing IIFE escape attacks and response forgery.
34 */
35
36import { WorkerEntrypoint } from "cloudflare:workers";
37
38// ── Types ────────────────────────────────────────────────────────────────────
39
40interface Env {
41 LOADER: WorkerLoader;
42 AUTH_TOKEN: string;
43}
44
45/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */
46interface WorkerLoader {
47 get(id: string, getCode: () => Promise<WorkerCode>): WorkerStub;
48}
49
50interface WorkerCode {
51 compatibilityDate: string;
52 compatibilityFlags?: string[];
53 mainModule: string;
54 modules: Record<string, string | { js: string } | { text: string } | { json: object }>;
55 env?: Record<string, unknown>;
56 globalOutbound?: unknown | null;
57}
58
59interface WorkerStub {
60 getEntrypoint(name?: string, options?: { props?: unknown }): EntrypointStub;
61}
62
63interface EntrypointStub {
64 fetch(input: string | Request, init?: RequestInit): Promise<Response>;
65}
66
67interface RunRequest {
68 taskId: string;
69 code: string;
70 timeoutMs: number;
71 callback: {
72 baseUrl: string;
73 authToken: string;
74 };
75}
76
77interface RunResult {
78 status: "completed" | "failed" | "timed_out" | "denied";
79 stdout: string;
80 stderr: string;
81 error?: string;
82 exitCode?: number;
83}
84
85interface ToolCallResult {
86 ok: boolean;
87 value?: unknown;
88 error?: string;
89 denied?: boolean;
90}
91
92interface BridgeProps {
93 callbackBaseUrl: string;
94 callbackAuthToken: string;
95 taskId: string;
96}
97
98// ── Auth ──────────────────────────────────────────────────────────────────────
99
100/** Constant-time string comparison to prevent timing side-channels. */
101function timingSafeEqual(a: string, b: string): boolean {
102 const encoder = new TextEncoder();
103 const bufA = encoder.encode(a);
104 const bufB = encoder.encode(b);
105
106 if (bufA.length !== bufB.length) {
107 // Compare against self to keep timing consistent, then return false.
108 let result = 0;
109 for (let i = 0; i < bufA.length; i++) {
110 result |= (bufA[i] ?? 0) ^ (bufA[i] ?? 0);
111 }
112 return false;
113 }
114
115 let result = 0;
116 for (let i = 0; i < bufA.length; i++) {
117 result |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0);
118 }
119 return result === 0;
120}
121
122// ── Tool Bridge Entrypoint ───────────────────────────────────────────────────
123//
124// This class is exposed as a named entrypoint on the host Worker. A loopback
125// service binding (via `ctx.exports.ToolBridge({props: ...})`) is passed into
126// the dynamic isolate's `env`. When the isolate calls
127// `env.TOOL_BRIDGE.callTool(...)`, the RPC call lands here.
128//
129// `this.ctx.props` carries the callback URL and auth token for the specific task.
130
131export class ToolBridge extends WorkerEntrypoint<Env> {
132 private get props(): BridgeProps {
133 return (this.ctx as unknown as { props: BridgeProps }).props;
134 }
135
136 /** Forward a tool call to the Convex internal HTTP API. */
137 async callTool(toolPath: string, input: unknown): Promise<ToolCallResult> {
138 const { callbackBaseUrl, callbackAuthToken, taskId } = this.props;
139 const url = `${callbackBaseUrl}/internal/runs/${taskId}/tool-call`;
140 const callId = `call_${crypto.randomUUID()}`;
141
142 const response = await fetch(url, {
143 method: "POST",
144 headers: {
145 "Content-Type": "application/json",
146 Authorization: `Bearer ${callbackAuthToken}`,
147 },
148 body: JSON.stringify({ callId, toolPath, input }),
149 });
150
151 if (!response.ok) {
152 const text = await response.text().catch(() => response.statusText);
153 return { ok: false, error: `Tool callback failed (${response.status}): ${text}` };
154 }
155
156 return (await response.json()) as ToolCallResult;
157 }
158
159 /** Stream a console output line back to Convex (best-effort). */
160 async emitOutput(stream: "stdout" | "stderr", line: string): Promise<void> {
161 const { callbackBaseUrl, callbackAuthToken, taskId } = this.props;
162 const url = `${callbackBaseUrl}/internal/runs/${taskId}/output`;
163
164 try {
165 await fetch(url, {
166 method: "POST",
167 headers: {
168 "Content-Type": "application/json",
169 Authorization: `Bearer ${callbackAuthToken}`,
170 },
171 body: JSON.stringify({ stream, line, timestamp: Date.now() }),
172 });
173 } catch {
174 // Swallow — output streaming is best-effort.
175 }
176 }
177}
178
179// ── Sandbox Harness ──────────────────────────────────────────────────────────
180//
181// The harness is a static ES module loaded as the main module of the dynamic
182// isolate. User code lives in a **separate** module (`user-code.js`) and is
183// imported by the harness. This prevents user code from accessing or
184// manipulating the harness's fetch handler, `req`, `env`, `ctx`, or `Response`.
185
186const GLOBALS_MODULE = `
187// Captured before user code module is evaluated (this module is imported first).
188export const ResponseJson = Response.json.bind(Response);
189`;
190
191const HARNESS_CODE = `
192import { ResponseJson as _ResponseJson } from "./globals.js";
193import { run } from "./user-code.js";
194
195const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
196
197function formatArgs(args) {
198 return args.map((v) => {
199 if (typeof v === "string") return v;
200 try { return JSON.stringify(v); }
201 catch { return String(v); }
202 }).join(" ");
203}
204
205function createToolsProxy(bridge, path = []) {
206 const callable = () => {};
207 return new Proxy(callable, {
208 get(_target, prop) {
209 if (prop === "then") return undefined;
210 if (typeof prop !== "string") return undefined;
211 return createToolsProxy(bridge, [...path, prop]);
212 },
213 async apply(_target, _thisArg, args) {
214 const toolPath = path.join(".");
215 if (!toolPath) throw new Error("Tool path missing");
216 const input = args.length > 0 ? args[0] : {};
217 const result = await bridge.callTool(toolPath, input);
218 if (result.ok) return result.value;
219 if (result.denied) throw new Error(APPROVAL_DENIED_PREFIX + result.error);
220 throw new Error(result.error);
221 },
222 });
223}
224
225export default {
226 async fetch(req, env, ctx) {
227 const stdoutLines = [];
228 const stderrLines = [];
229
230 const appendStdout = (line) => {
231 stdoutLines.push(line);
232 ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line));
233 };
234 const appendStderr = (line) => {
235 stderrLines.push(line);
236 ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line));
237 };
238
239 const tools = createToolsProxy(env.TOOL_BRIDGE);
240 const console = {
241 log: (...args) => appendStdout(formatArgs(args)),
242 info: (...args) => appendStdout(formatArgs(args)),
243 warn: (...args) => appendStderr(formatArgs(args)),
244 error: (...args) => appendStderr(formatArgs(args)),
245 };
246
247 try {
248 const value = await run(tools, console);
249
250 if (value !== undefined) {
251 appendStdout("result: " + formatArgs([value]));
252 }
253
254 return _ResponseJson({
255 status: "completed",
256 stdout: stdoutLines.join("\\n"),
257 stderr: stderrLines.join("\\n"),
258 exitCode: 0,
259 });
260 } catch (error) {
261 const message = error instanceof Error ? error.message : String(error);
262 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
263 const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
264 appendStderr(denied);
265 return _ResponseJson({
266 status: "denied",
267 stdout: stdoutLines.join("\\n"),
268 stderr: stderrLines.join("\\n"),
269 error: denied,
270 });
271 }
272 appendStderr(message);
273 return _ResponseJson({
274 status: "failed",
275 stdout: stdoutLines.join("\\n"),
276 stderr: stderrLines.join("\\n"),
277 error: message,
278 });
279 }
280 },
281};
282`;
283
284/**
285 * Build the user code module. The code is wrapped in an exported async
286 * function `run(tools, console)` so the harness can call it with controlled
287 * scope bindings. The user code runs in a separate module from the harness
288 * and cannot access `req`, `env`, `ctx`, or `Response`.
289 */
290function buildUserModule(userCode: string): string {
291 return `export async function run(tools, console) {\n"use strict";\n${userCode}\n}\n`;
292}
293
294// ── Main Handler ─────────────────────────────────────────────────────────────
295
296export default {
297 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
298 const url = new URL(request.url);
299
300 if (request.method === "GET" && url.pathname === "/health") {
301 return Response.json({ ok: true });
302 }
303
304 if (request.method !== "POST" || url.pathname !== "/v1/runs") {
305 return Response.json({ error: "Not found" }, { status: 404 });
306 }
307
308 // ── Auth ──────────────────────────────────────────────────────────────
309 const authHeader = request.headers.get("authorization");
310 if (!authHeader?.startsWith("Bearer ")) {
311 return Response.json({ error: "Unauthorized" }, { status: 401 });
312 }
313 const token = authHeader.slice("Bearer ".length);
314 if (!timingSafeEqual(token, env.AUTH_TOKEN)) {
315 return Response.json({ error: "Unauthorized" }, { status: 401 });
316 }
317
318 // ── Parse body ────────────────────────────────────────────────────────
319 let body: RunRequest;
320 try {
321 body = (await request.json()) as RunRequest;
322 } catch {
323 return Response.json({ error: "Invalid JSON body" }, { status: 400 });
324 }
325
326 if (!body.taskId || !body.code || !body.callback?.baseUrl || !body.callback?.authToken) {
327 return Response.json(
328 { error: "Missing required fields: taskId, code, callback.baseUrl, callback.authToken" },
329 { status: 400 },
330 );
331 }
332
333 const timeoutMs = body.timeoutMs ?? 300_000;
334 const isolateId = body.taskId;
335
336 try {
337 const ctxExports = (ctx as unknown as {
338 exports: Record<string, (opts: { props: BridgeProps }) => unknown>;
339 }).exports;
340
341 const toolBridgeBinding = ctxExports.ToolBridge({
342 props: {
343 callbackBaseUrl: body.callback.baseUrl,
344 callbackAuthToken: body.callback.authToken,
345 taskId: body.taskId,
346 },
347 });
348
349 const worker = env.LOADER.get(isolateId, async () => ({
350 compatibilityDate: "2025-06-01",
351 mainModule: "harness.js",
352 modules: {
353 "harness.js": HARNESS_CODE,
354 // Globals captured before user code is evaluated.
355 "globals.js": GLOBALS_MODULE,
356 // User code is in a separate module — it exports run(tools, console)
357 // and cannot access the harness's fetch handler scope.
358 "user-code.js": buildUserModule(body.code),
359 },
360 env: {
361 TOOL_BRIDGE: toolBridgeBinding,
362 },
363 globalOutbound: null,
364 }));
365
366 const entrypoint = worker.getEntrypoint();
367
368 const controller = new AbortController();
369 const timer = setTimeout(() => controller.abort(), timeoutMs);
370
371 try {
372 const response = await entrypoint.fetch("http://sandbox.internal/run", {
373 method: "POST",
374 signal: controller.signal,
375 });
376 const result = (await response.json()) as RunResult;
377 return Response.json(result);
378 } catch (error) {
379 if (error instanceof DOMException && error.name === "AbortError") {
380 return Response.json({
381 status: "timed_out",
382 stdout: "",
383 stderr: "",
384 error: `Execution timed out after ${timeoutMs}ms`,
385 } satisfies RunResult);
386 }
387 throw error;
388 } finally {
389 clearTimeout(timer);
390 }
391 } catch (error) {
392 const message = error instanceof Error ? error.message : String(error);
393 return Response.json({
394 status: "failed",
395 stdout: "",
396 stderr: "",
397 error: `Sandbox host error: ${message}`,
398 } satisfies RunResult);
399 }
400 },
401};
executor/packages/sandbox-host/tsconfig.json+17−0
@@ -0,0 +1,17 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "ESNext",
5 "moduleResolution": "bundler",
6 "lib": ["ESNext"],
7 "types": ["@cloudflare/workers-types"],
8 "strict": true,
9 "noEmit": true,
10 "skipLibCheck": true,
11 "forceConsistentCasingInFileNames": true,
12 "resolveJsonModule": true,
13 "isolatedModules": true,
14 "jsx": "react-jsx"
15 },
16 "include": ["src/**/*.ts"]
17}
executor/packages/sandbox-host/wrangler.jsonc+20−0
@@ -0,0 +1,20 @@
1{
2 "name": "executor-sandbox-host",
3 "main": "src/index.ts",
4 "compatibility_date": "2025-06-01",
5 "compatibility_flags": ["enable_ctx_exports"],
6
7 // The Worker Loader binding — this is the core of the Dynamic Worker Loader API.
8 // It provides `env.LOADER.get(id, callback)` to spawn sandboxed isolates.
9 "worker_loaders": [
10 {
11 "binding": "LOADER"
12 }
13 ]
14
15 // Environment secrets (set via `wrangler secret put AUTH_TOKEN`):
16 // AUTH_TOKEN: shared-secret bearer token that the Convex executor uses
17 // to authenticate run requests.
18 //
19 // See .dev.vars.example for local development values.
20}