History

Add Cloudflare Workers sandbox runtime for isolated code execution

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

Rhys Sullivan committed 9 months ago

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

Unified · Split

13 files
  1. .gitignore +9 −0
  2. executor/convex/executor.ts +2 −1
  3. executor/convex/executorNode.ts +65 −22
  4. executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts +329 −0
  5. executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +141 −0
  6. executor/lib/runtimes/runtime_catalog.ts +82 −0
  7. executor/lib/runtimes/runtime_core.ts +1 −28
  8. executor/lib/runtimes/transpile.ts +38 −0
  9. executor/packages/sandbox-host/.dev.vars.example +2 −0
  10. executor/packages/sandbox-host/package.json +16 −0
  11. executor/packages/sandbox-host/src/index.ts +401 −0
  12. executor/packages/sandbox-host/tsconfig.json +17 −0
  13. executor/packages/sandbox-host/wrangler.jsonc +20 −0
.gitignore+9−0
@@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json · 38 unmodified lines
3939 convex_local_backend.sqlite3
4040 convex_local_storage/
4141
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+
4251 # Generated push env file
4352 executor/.env.executor-push
4453
executor/convex/executor.ts+2−1
@@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines
55 import { internalMutation } from "./_generated/server";
66 import { workspaceMutation } from "../lib/functionBuilders";
77 import { actorIdForAccount } from "../lib/identity";
8+import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog";
89 import type { ApprovalRecord, TaskRecord } from "../lib/types";
910
1011 const DEFAULT_TIMEOUT_MS = 300_000;
@@ -38,7 +39,7 @@ async function createTaskRecord( · 27 unmodified lines
3839 }
3940
4041 const runtimeId = args.runtimeId ?? "local-bun";
41− if (runtimeId !== "local-bun") {
42+ if (!isKnownRuntimeId(runtimeId)) {
4243 throw new Error(`Unsupported runtime: ${runtimeId}`);
4344 }
4445
executor/convex/executorNode.ts+65−22
@@ -9,6 +9,13 @@ import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_ · 8 unmodified lines
99 import { resolveCredentialPayload } from "../lib/credential_providers";
1010 import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants";
1111 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";
1219 import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";
1320 import { createDiscoverTool } from "../lib/tool_discovery";
1421 import {
@@ -1089,7 +1096,7 @@ export const runTask = internalAction({ · 1074 unmodified lines
10891096 return null;
10901097 }
10911098
1092− if (task.runtimeId !== "local-bun") {
1099+ if (!isKnownRuntimeId(task.runtimeId)) {
10931100 const failed = await ctx.runMutation(internal.database.markTaskFinished, {
10941101 taskId: args.taskId,
10951102 status: "failed",
@@ -1108,6 +1115,25 @@ export const runTask = internalAction({ · 12 unmodified lines
11081115 return null;
11091116 }
11101117
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+
11111137 try {
11121138 const running = (await ctx.runMutation(internal.database.markTaskRunning, {
11131139 taskId: args.taskId,
@@ -1122,27 +1148,44 @@ export const runTask = internalAction({ · 8 unmodified lines
11221148 startedAt: running.startedAt,
11231149 });
11241150
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+ };
11461189
11471190 const finished = await ctx.runMutation(internal.database.markTaskFinished, {
11481191 taskId: args.taskId,
executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts+329−0
@@ -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+});
executor/lib/runtimes/cloudflare_worker_loader_runtime.ts+141−0
@@ -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+}
executor/lib/runtimes/runtime_catalog.ts+82−0
@@ -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+}
executor/lib/runtimes/runtime_core.ts+1−28
@@ -11,34 +11,7 @@ import type { · 10 unmodified lines
1111 SandboxExecutionRequest,
1212 SandboxExecutionResult,
1313 } 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";
4215
4316 function formatArgs(args: unknown[]): string {
4417 return args
executor/lib/runtimes/transpile.ts+38−0
@@ -0,0 +1,38 @@
1+"use node";
2+
3+/**
4+ * Transpile TypeScript code to JavaScript using the `typescript` module.
5+ *
6+ * If the `typescript` module is not available, the code is returned as-is
7+ * (graceful fallback for environments where TS isn't installed).
8+ *
9+ * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm,
10+ * Cloudflare Workers isolates, etc.) without further downlevelling.
11+ */
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+}
executor/packages/sandbox-host/.dev.vars.example+2−0
@@ -0,0 +1,2 @@
1+# Shared-secret bearer token — must match CLOUDFLARE_SANDBOX_AUTH_TOKEN on the executor side.
2+AUTH_TOKEN=dev-sandbox-token
executor/packages/sandbox-host/package.json+16−0
@@ -0,0 +1,16 @@
1+{
2+ "name": "@executor/sandbox-host",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "wrangler dev --port 8787",
8+ "deploy": "wrangler deploy",
9+ "typecheck": "tsc --noEmit"
10+ },
11+ "devDependencies": {
12+ "@cloudflare/workers-types": "^4.20250214.0",
13+ "typescript": "^5.9.3",
14+ "wrangler": "^4.0.0"
15+ }
16+}
executor/packages/sandbox-host/src/index.ts+401−0
@@ -0,0 +1,401 @@
1+/**
2+ * Executor Sandbox Host Worker
3+ *
4+ * This Cloudflare Worker uses the Dynamic Worker Loader API to run
5+ * agent-generated code in sandboxed isolates. It exposes a single HTTP
6+ * endpoint (`POST /v1/runs`) that the executor's Convex action calls.
7+ *
8+ * ## How it works
9+ *
10+ * 1. Receives a run request with `{ taskId, code, timeoutMs, callback }`.
11+ *
12+ * 2. Uses `env.LOADER.get(id, () => WorkerCode)` to spawn a dynamic isolate
13+ * containing the user's code.
14+ *
15+ * 3. The isolate's network access is fully blocked (`globalOutbound: null`).
16+ * Instead, tool calls are routed through a `ToolBridge` entrypoint class
17+ * (passed as a loopback service binding via `ctx.exports`) which calls back
18+ * to the Convex HTTP API to resolve them.
19+ *
20+ * 4. Console output is buffered in the harness and returned in the response.
21+ * Output lines are also streamed back to Convex in real-time via the
22+ * ToolBridge binding.
23+ *
24+ * 5. The result (status, stdout, stderr, error) is returned as JSON.
25+ *
26+ * ## Code isolation
27+ *
28+ * User code is placed in a **separate JS module** (`user-code.js`) that
29+ * exports a single `run(tools, console)` async function. The harness module
30+ * (`harness.js`) imports and calls this function, passing controlled `tools`
31+ * and `console` proxies. Because the user code is in a different module, it
32+ * cannot access the harness's `fetch` handler scope, `req`, `env`, `ctx`,
33+ * or `Response` — preventing IIFE escape attacks and response forgery.
34+ */
35+
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+};
executor/packages/sandbox-host/tsconfig.json+17−0
@@ -0,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ESNext",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ESNext"],
7+ "types": ["@cloudflare/workers-types"],
8+ "strict": true,
9+ "noEmit": true,
10+ "skipLibCheck": true,
11+ "forceConsistentCasingInFileNames": true,
12+ "resolveJsonModule": true,
13+ "isolatedModules": true,
14+ "jsx": "react-jsx"
15+ },
16+ "include": ["src/**/*.ts"]
17+}
executor/packages/sandbox-host/wrangler.jsonc+20−0
@@ -0,0 +1,20 @@
1+{
2+ "name": "executor-sandbox-host",
3+ "main": "src/index.ts",
4+ "compatibility_date": "2025-06-01",
5+ "compatibility_flags": ["enable_ctx_exports"],
6+
7+ // The Worker Loader binding — this is the core of the Dynamic Worker Loader API.
8+ // It provides `env.LOADER.get(id, callback)` to spawn sandboxed isolates.
9+ "worker_loaders": [
10+ {
11+ "binding": "LOADER"
12+ }
13+ ]
14+
15+ // Environment secrets (set via `wrangler secret put AUTH_TOKEN`):
16+ // AUTH_TOKEN: shared-secret bearer token that the Convex executor uses
17+ // to authenticate run requests.
18+ //
19+ // See .dev.vars.example for local development values.
20+}