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 | convex_local_backend.sqlite3 | 39 | convex_local_backend.sqlite3 |
| 40 | convex_local_storage/ | 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 | # Generated push env file | 51 | # Generated push env file |
| 43 | executor/.env.executor-push | 52 | executor/.env.executor-push |
| 44 | 53 | ||
| @@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines | |||
| 5 | import { internalMutation } from "./_generated/server"; | 5 | import { internalMutation } from "./_generated/server"; |
| 6 | import { workspaceMutation } from "../lib/functionBuilders"; | 6 | import { workspaceMutation } from "../lib/functionBuilders"; |
| 7 | import { actorIdForAccount } from "../lib/identity"; | 7 | import { actorIdForAccount } from "../lib/identity"; |
| 8 | import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog"; | ||
| 8 | import type { ApprovalRecord, TaskRecord } from "../lib/types"; | 9 | import type { ApprovalRecord, TaskRecord } from "../lib/types"; |
| 9 | 10 | ||
| 10 | const DEFAULT_TIMEOUT_MS = 300_000; | 11 | const 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 | ||
| @@ -9,6 +9,13 @@ import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_ · 8 unmodified lines | |||
| 9 | import { resolveCredentialPayload } from "../lib/credential_providers"; | 9 | import { resolveCredentialPayload } from "../lib/credential_providers"; |
| 10 | import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; | 10 | import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; |
| 11 | import { actorIdForAccount } from "../lib/identity"; | 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 | import { runCodeWithAdapter } from "../lib/runtimes/runtime_core"; | 19 | import { runCodeWithAdapter } from "../lib/runtimes/runtime_core"; |
| 13 | import { createDiscoverTool } from "../lib/tool_discovery"; | 20 | import { createDiscoverTool } from "../lib/tool_discovery"; |
| 14 | import { | 21 | import { |
| @@ -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, |
| @@ -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 | SandboxExecutionRequest, | 11 | SandboxExecutionRequest, |
| 12 | SandboxExecutionResult, | 12 | SandboxExecutionResult, |
| 13 | } from "../types"; | 13 | } from "../types"; |
| 14 | 14 | import { transpileForRuntime } from "./transpile"; | |
| 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 | } | ||
| 42 | 15 | ||
| 43 | function formatArgs(args: unknown[]): string { | 16 | function formatArgs(args: unknown[]): string { |
| 44 | return args | 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 | } | ||