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.
Unified · Split
| @@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json · 38 unmodified lines | ||
| 39 | 39 | convex_local_backend.sqlite3 |
| 40 | 40 | convex_local_storage/ |
| 41 | 41 | |
| 42 | +# Local OpenAPI sources service state | |
| 43 | +sources/data/*.sqlite | |
| 44 | +sources/data/*.sqlite-shm | |
| 45 | +sources/data/*.sqlite-wal | |
| 46 | + | |
| 47 | +# Cloudflare Workers local state | |
| 48 | +.wrangler/ | |
| 49 | +.dev.vars | |
| 50 | + | |
| 42 | 51 | # Generated push env file |
| 43 | 52 | executor/.env.executor-push |
| 44 | 53 | |
| @@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines | ||
| 5 | 5 | import { internalMutation } from "./_generated/server"; |
| 6 | 6 | import { workspaceMutation } from "../lib/functionBuilders"; |
| 7 | 7 | import { actorIdForAccount } from "../lib/identity"; |
| 8 | +import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog"; | |
| 8 | 9 | import type { ApprovalRecord, TaskRecord } from "../lib/types"; |
| 9 | 10 | |
| 10 | 11 | const DEFAULT_TIMEOUT_MS = 300_000; |
| @@ -38,7 +39,7 @@ async function createTaskRecord( · 27 unmodified lines | ||
| 38 | 39 | } |
| 39 | 40 | |
| 40 | 41 | const runtimeId = args.runtimeId ?? "local-bun"; |
| 41 | − if (runtimeId !== "local-bun") { | |
| 42 | + if (!isKnownRuntimeId(runtimeId)) { | |
| 42 | 43 | throw new Error(`Unsupported runtime: ${runtimeId}`); |
| 43 | 44 | } |
| 44 | 45 | |
| @@ -9,6 +9,13 @@ import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_ · 8 unmodified lines | ||
| 9 | 9 | import { resolveCredentialPayload } from "../lib/credential_providers"; |
| 10 | 10 | import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; |
| 11 | 11 | import { actorIdForAccount } from "../lib/identity"; |
| 12 | +import { runCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime"; | |
| 13 | +import { | |
| 14 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 15 | + isCloudflareWorkerLoaderConfigured, | |
| 16 | + isKnownRuntimeId, | |
| 17 | + LOCAL_BUN_RUNTIME_ID, | |
| 18 | +} from "../lib/runtimes/runtime_catalog"; | |
| 12 | 19 | import { runCodeWithAdapter } from "../lib/runtimes/runtime_core"; |
| 13 | 20 | import { createDiscoverTool } from "../lib/tool_discovery"; |
| 14 | 21 | import { |
| @@ -1089,7 +1096,7 @@ export const runTask = internalAction({ · 1074 unmodified lines | ||
| 1089 | 1096 | return null; |
| 1090 | 1097 | } |
| 1091 | 1098 | |
| 1092 | − if (task.runtimeId !== "local-bun") { | |
| 1099 | + if (!isKnownRuntimeId(task.runtimeId)) { | |
| 1093 | 1100 | const failed = await ctx.runMutation(internal.database.markTaskFinished, { |
| 1094 | 1101 | taskId: args.taskId, |
| 1095 | 1102 | status: "failed", |
| @@ -1108,6 +1115,25 @@ export const runTask = internalAction({ · 12 unmodified lines | ||
| 1108 | 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 | 1137 | try { |
| 1112 | 1138 | const running = (await ctx.runMutation(internal.database.markTaskRunning, { |
| 1113 | 1139 | taskId: args.taskId, |
| @@ -1122,27 +1148,44 @@ export const runTask = internalAction({ · 8 unmodified lines | ||
| 1122 | 1148 | startedAt: running.startedAt, |
| 1123 | 1149 | }); |
| 1124 | 1150 | |
| 1125 | − const adapter = new InProcessExecutionAdapter({ | |
| 1126 | − runId: args.taskId, | |
| 1127 | − invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 1128 | − emitOutput: async (event) => { | |
| 1129 | − await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 1130 | − runId: event.runId, | |
| 1131 | − stream: event.stream, | |
| 1132 | − line: event.line, | |
| 1133 | − timestamp: event.timestamp, | |
| 1134 | − }); | |
| 1135 | − }, | |
| 1136 | − }); | |
| 1137 | − | |
| 1138 | − const runtimeResult = await runCodeWithAdapter( | |
| 1139 | − { | |
| 1140 | − taskId: args.taskId, | |
| 1141 | − code: running.code, | |
| 1142 | − timeoutMs: running.timeoutMs, | |
| 1143 | − }, | |
| 1144 | − adapter, | |
| 1145 | − ); | |
| 1151 | + const runtimeResult = | |
| 1152 | + running.runtimeId === LOCAL_BUN_RUNTIME_ID | |
| 1153 | + ? await (async () => { | |
| 1154 | + const adapter = new InProcessExecutionAdapter({ | |
| 1155 | + runId: args.taskId, | |
| 1156 | + invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 1157 | + emitOutput: async (event) => { | |
| 1158 | + await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 1159 | + runId: event.runId, | |
| 1160 | + stream: event.stream, | |
| 1161 | + line: event.line, | |
| 1162 | + timestamp: event.timestamp, | |
| 1163 | + }); | |
| 1164 | + }, | |
| 1165 | + }); | |
| 1166 | + | |
| 1167 | + return await runCodeWithAdapter( | |
| 1168 | + { | |
| 1169 | + taskId: args.taskId, | |
| 1170 | + code: running.code, | |
| 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 | 1190 | const finished = await ctx.runMutation(internal.database.markTaskFinished, { |
| 1148 | 1191 | taskId: args.taskId, |
| @@ -0,0 +1,329 @@ | ||
| 1 | +import { expect, test, describe, beforeAll, afterAll } from "bun:test"; | |
| 2 | +import { 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 | + | |
| 10 | +let fakeHostServer: ReturnType<typeof Bun.serve>; | |
| 11 | +let fakeCallbackServer: ReturnType<typeof Bun.serve>; | |
| 12 | + | |
| 13 | +const AUTH_TOKEN = "test-sandbox-token"; | |
| 14 | +const CALLBACK_TOKEN = "test-callback-token"; | |
| 15 | + | |
| 16 | +const toolResponses = new Map<string, unknown>(); | |
| 17 | +const capturedOutputs: Array<{ stream: string; line: string }> = []; | |
| 18 | + | |
| 19 | +beforeAll(() => { | |
| 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 | + | |
| 179 | +afterAll(() => { | |
| 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 | + | |
| 189 | +describe("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 | + | |
| 300 | +describe("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 | +}); | |
| @@ -0,0 +1,141 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; | |
| 4 | +import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; | |
| 5 | +import { 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 | + */ | |
| 35 | +export 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 | + | |
| 128 | +function 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 | +} | |
| @@ -0,0 +1,82 @@ | ||
| 1 | +// ── Runtime ID constants ────────────────────────────────────────────────────── | |
| 2 | + | |
| 3 | +export const LOCAL_BUN_RUNTIME_ID = "local-bun"; | |
| 4 | +export const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader"; | |
| 5 | + | |
| 6 | +const KNOWN_RUNTIME_IDS = new Set([ | |
| 7 | + LOCAL_BUN_RUNTIME_ID, | |
| 8 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 9 | +]); | |
| 10 | + | |
| 11 | +export function isKnownRuntimeId(runtimeId: string): boolean { | |
| 12 | + return KNOWN_RUNTIME_IDS.has(runtimeId); | |
| 13 | +} | |
| 14 | + | |
| 15 | +// ── Cloudflare Worker Loader config ────────────────────────────────────────── | |
| 16 | + | |
| 17 | +export 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 | + */ | |
| 34 | +export 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 | + */ | |
| 45 | +export 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 | +} | |
| @@ -11,34 +11,7 @@ import type { · 10 unmodified lines | ||
| 11 | 11 | SandboxExecutionRequest, |
| 12 | 12 | SandboxExecutionResult, |
| 13 | 13 | } from "../types"; |
| 14 | − | |
| 15 | −async 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 | −} | |
| 14 | +import { transpileForRuntime } from "./transpile"; | |
| 42 | 15 | |
| 43 | 16 | function formatArgs(args: unknown[]): string { |
| 44 | 17 | return args |
| @@ -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 | + */ | |
| 12 | +export 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 | +} | |
| @@ -0,0 +1,2 @@ | ||
| 1 | +# Shared-secret bearer token — must match CLOUDFLARE_SANDBOX_AUTH_TOKEN on the executor side. | |
| 2 | +AUTH_TOKEN=dev-sandbox-token | |
| @@ -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 | +} | |
| @@ -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 | + | |
| 36 | +import { WorkerEntrypoint } from "cloudflare:workers"; | |
| 37 | + | |
| 38 | +// ── Types ──────────────────────────────────────────────────────────────────── | |
| 39 | + | |
| 40 | +interface Env { | |
| 41 | + LOADER: WorkerLoader; | |
| 42 | + AUTH_TOKEN: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */ | |
| 46 | +interface WorkerLoader { | |
| 47 | + get(id: string, getCode: () => Promise<WorkerCode>): WorkerStub; | |
| 48 | +} | |
| 49 | + | |
| 50 | +interface 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 | + | |
| 59 | +interface WorkerStub { | |
| 60 | + getEntrypoint(name?: string, options?: { props?: unknown }): EntrypointStub; | |
| 61 | +} | |
| 62 | + | |
| 63 | +interface EntrypointStub { | |
| 64 | + fetch(input: string | Request, init?: RequestInit): Promise<Response>; | |
| 65 | +} | |
| 66 | + | |
| 67 | +interface RunRequest { | |
| 68 | + taskId: string; | |
| 69 | + code: string; | |
| 70 | + timeoutMs: number; | |
| 71 | + callback: { | |
| 72 | + baseUrl: string; | |
| 73 | + authToken: string; | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +interface RunResult { | |
| 78 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 79 | + stdout: string; | |
| 80 | + stderr: string; | |
| 81 | + error?: string; | |
| 82 | + exitCode?: number; | |
| 83 | +} | |
| 84 | + | |
| 85 | +interface ToolCallResult { | |
| 86 | + ok: boolean; | |
| 87 | + value?: unknown; | |
| 88 | + error?: string; | |
| 89 | + denied?: boolean; | |
| 90 | +} | |
| 91 | + | |
| 92 | +interface 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. */ | |
| 101 | +function 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 | + | |
| 131 | +export 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 | + | |
| 186 | +const GLOBALS_MODULE = ` | |
| 187 | +// Captured before user code module is evaluated (this module is imported first). | |
| 188 | +export const ResponseJson = Response.json.bind(Response); | |
| 189 | +`; | |
| 190 | + | |
| 191 | +const HARNESS_CODE = ` | |
| 192 | +import { ResponseJson as _ResponseJson } from "./globals.js"; | |
| 193 | +import { run } from "./user-code.js"; | |
| 194 | + | |
| 195 | +const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; | |
| 196 | + | |
| 197 | +function 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 | + | |
| 205 | +function 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 | + | |
| 225 | +export 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 | + */ | |
| 290 | +function 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 | + | |
| 296 | +export 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 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |