@cloudflare/codemode 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Executor interface and DynamicWorkerExecutor implementation.
3
+ *
4
+ * The Executor interface is the core abstraction — implement it to run
5
+ * LLM-generated code in any sandbox (Workers, QuickJS, Node VM, etc.).
6
+ */
7
+
8
+ import { RpcTarget } from "cloudflare:workers";
9
+
10
+ export interface ExecuteResult {
11
+ result: unknown;
12
+ error?: string;
13
+ logs?: string[];
14
+ }
15
+
16
+ /**
17
+ * An executor runs LLM-generated code in a sandbox, making the provided
18
+ * tool functions callable as `codemode.*` inside the sandbox.
19
+ *
20
+ * Implementations should never throw — errors are returned in `ExecuteResult.error`.
21
+ */
22
+ export interface Executor {
23
+ execute(
24
+ code: string,
25
+ fns: Record<string, (...args: unknown[]) => Promise<unknown>>
26
+ ): Promise<ExecuteResult>;
27
+ }
28
+
29
+ // -- ToolDispatcher (RPC target for tool calls from sandboxed Workers) --
30
+
31
+ /**
32
+ * An RpcTarget that dispatches tool calls from the sandboxed Worker
33
+ * back to the host. Passed via Workers RPC to the dynamic Worker's
34
+ * evaluate() method — no globalOutbound or Fetcher bindings needed.
35
+ */
36
+ export class ToolDispatcher extends RpcTarget {
37
+ #fns: Record<string, (...args: unknown[]) => Promise<unknown>>;
38
+
39
+ constructor(fns: Record<string, (...args: unknown[]) => Promise<unknown>>) {
40
+ super();
41
+ this.#fns = fns;
42
+ }
43
+
44
+ async call(name: string, argsJson: string): Promise<string> {
45
+ const fn = this.#fns[name];
46
+ if (!fn) {
47
+ return JSON.stringify({ error: `Tool "${name}" not found` });
48
+ }
49
+ try {
50
+ const args = argsJson ? JSON.parse(argsJson) : {};
51
+ const result = await fn(args);
52
+ return JSON.stringify({ result });
53
+ } catch (err) {
54
+ return JSON.stringify({
55
+ error: err instanceof Error ? err.message : String(err)
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ // -- DynamicWorkerExecutor (Cloudflare Workers) --
62
+
63
+ export interface DynamicWorkerExecutorOptions {
64
+ loader: WorkerLoader;
65
+ /**
66
+ * Timeout in milliseconds for code execution. Defaults to 30000 (30s).
67
+ */
68
+ timeout?: number;
69
+ /**
70
+ * Controls outbound network access from sandboxed code.
71
+ * - `null` (default): fetch() and connect() throw — sandbox is fully isolated.
72
+ * - `undefined`: inherits parent Worker's network access (full internet).
73
+ * - A `Fetcher`: all outbound requests route through this handler.
74
+ */
75
+ globalOutbound?: Fetcher | null;
76
+ }
77
+
78
+ /**
79
+ * Executes code in an isolated Cloudflare Worker via WorkerLoader.
80
+ * Tool calls are dispatched via Workers RPC — the host passes a
81
+ * ToolDispatcher (RpcTarget) to the Worker's evaluate() method.
82
+ *
83
+ * External fetch() and connect() are blocked by default via
84
+ * `globalOutbound: null` (runtime-enforced). Pass a Fetcher to
85
+ * `globalOutbound` to allow controlled outbound access.
86
+ */
87
+ export class DynamicWorkerExecutor implements Executor {
88
+ #loader: WorkerLoader;
89
+ #timeout: number;
90
+ #globalOutbound: Fetcher | null;
91
+
92
+ constructor(options: DynamicWorkerExecutorOptions) {
93
+ this.#loader = options.loader;
94
+ this.#timeout = options.timeout ?? 30000;
95
+ this.#globalOutbound = options.globalOutbound ?? null;
96
+ }
97
+
98
+ async execute(
99
+ code: string,
100
+ fns: Record<string, (...args: unknown[]) => Promise<unknown>>
101
+ ): Promise<ExecuteResult> {
102
+ const timeoutMs = this.#timeout;
103
+
104
+ const modulePrefix = [
105
+ 'import { WorkerEntrypoint } from "cloudflare:workers";',
106
+ "",
107
+ "export default class CodeExecutor extends WorkerEntrypoint {",
108
+ " async evaluate(dispatcher) {",
109
+ " const __logs = [];",
110
+ ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };',
111
+ ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };',
112
+ ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };',
113
+ " const codemode = new Proxy({}, {",
114
+ " get: (_, toolName) => async (args) => {",
115
+ " const resJson = await dispatcher.call(String(toolName), JSON.stringify(args ?? {}));",
116
+ " const data = JSON.parse(resJson);",
117
+ " if (data.error) throw new Error(data.error);",
118
+ " return data.result;",
119
+ " }",
120
+ " });",
121
+ "",
122
+ " try {",
123
+ " const result = await Promise.race([",
124
+ " ("
125
+ ].join("\n");
126
+
127
+ const moduleSuffix = [
128
+ ")(),",
129
+ ' new Promise((_, reject) => setTimeout(() => reject(new Error("Execution timed out")), ' +
130
+ timeoutMs +
131
+ "))",
132
+ " ]);",
133
+ " return { result, logs: __logs };",
134
+ " } catch (err) {",
135
+ " return { result: undefined, error: err.message, logs: __logs };",
136
+ " }",
137
+ " }",
138
+ "}"
139
+ ].join("\n");
140
+
141
+ const executorModule = modulePrefix + code + moduleSuffix;
142
+
143
+ const dispatcher = new ToolDispatcher(fns);
144
+
145
+ const worker = this.#loader.get(`codemode-${crypto.randomUUID()}`, () => ({
146
+ compatibilityDate: "2025-06-01",
147
+ compatibilityFlags: ["nodejs_compat"],
148
+ mainModule: "executor.js",
149
+ modules: {
150
+ "executor.js": executorModule
151
+ },
152
+ globalOutbound: this.#globalOutbound
153
+ }));
154
+
155
+ const entrypoint = worker.getEntrypoint() as unknown as {
156
+ evaluate(dispatcher: ToolDispatcher): Promise<{
157
+ result: unknown;
158
+ error?: string;
159
+ logs?: string[];
160
+ }>;
161
+ };
162
+ const response = await entrypoint.evaluate(dispatcher);
163
+
164
+ if (response.error) {
165
+ return { result: undefined, error: response.error, logs: response.logs };
166
+ }
167
+
168
+ return { result: response.result, logs: response.logs };
169
+ }
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export {
2
+ DynamicWorkerExecutor,
3
+ ToolDispatcher,
4
+ type DynamicWorkerExecutorOptions,
5
+ type Executor,
6
+ type ExecuteResult
7
+ } from "./executor";
8
+ export {
9
+ generateTypes,
10
+ sanitizeToolName,
11
+ type ToolDescriptor,
12
+ type ToolDescriptors
13
+ } from "./types";
@@ -0,0 +1,5 @@
1
+ declare module "cloudflare:test" {
2
+ interface ProvidedEnv {
3
+ LOADER: WorkerLoader;
4
+ }
5
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for the Executor interface contract and DynamicWorkerExecutor.
3
+ *
4
+ * Uses vitest-pool-workers — tests run inside a real Workers runtime
5
+ * with a real WorkerLoader binding, no mocks needed.
6
+ */
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { env } from "cloudflare:test";
9
+ import { DynamicWorkerExecutor, ToolDispatcher } from "../executor";
10
+
11
+ type ToolFns = Record<string, (...args: unknown[]) => Promise<unknown>>;
12
+
13
+ describe("ToolDispatcher", () => {
14
+ it("should dispatch tool calls and return JSON result", async () => {
15
+ const double = vi.fn(async (...args: unknown[]) => {
16
+ const input = args[0] as Record<string, unknown>;
17
+ return { doubled: (input.n as number) * 2 };
18
+ });
19
+ const fns: ToolFns = { double };
20
+ const dispatcher = new ToolDispatcher(fns);
21
+
22
+ const resJson = await dispatcher.call("double", JSON.stringify({ n: 5 }));
23
+ const data = JSON.parse(resJson);
24
+
25
+ expect(data.result).toEqual({ doubled: 10 });
26
+ expect(double).toHaveBeenCalledWith({ n: 5 });
27
+ });
28
+
29
+ it("should return error for unknown tool", async () => {
30
+ const dispatcher = new ToolDispatcher({});
31
+
32
+ const resJson = await dispatcher.call("nonexistent", "{}");
33
+ const data = JSON.parse(resJson);
34
+
35
+ expect(data.error).toContain("nonexistent");
36
+ });
37
+
38
+ it("should return error when tool function throws", async () => {
39
+ const fns: ToolFns = {
40
+ broken: async () => {
41
+ throw new Error("something broke");
42
+ }
43
+ };
44
+ const dispatcher = new ToolDispatcher(fns);
45
+
46
+ const resJson = await dispatcher.call("broken", "{}");
47
+ const data = JSON.parse(resJson);
48
+
49
+ expect(data.error).toBe("something broke");
50
+ });
51
+
52
+ it("should handle empty args string", async () => {
53
+ const noArgs = vi.fn(async () => "ok");
54
+ const fns: ToolFns = { noArgs };
55
+ const dispatcher = new ToolDispatcher(fns);
56
+
57
+ const resJson = await dispatcher.call("noArgs", "");
58
+ const data = JSON.parse(resJson);
59
+
60
+ expect(data.result).toBe("ok");
61
+ expect(noArgs).toHaveBeenCalledWith({});
62
+ });
63
+ });
64
+
65
+ describe("DynamicWorkerExecutor", () => {
66
+ it("should execute simple code that returns a value", async () => {
67
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
68
+
69
+ const result = await executor.execute("async () => 42", {});
70
+ expect(result.result).toBe(42);
71
+ expect(result.error).toBeUndefined();
72
+ });
73
+
74
+ it("should call tool functions via codemode proxy", async () => {
75
+ const add = vi.fn(async (...args: unknown[]) => {
76
+ const input = args[0] as Record<string, unknown>;
77
+ return (input.a as number) + (input.b as number);
78
+ });
79
+ const fns: ToolFns = { add };
80
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
81
+
82
+ const result = await executor.execute(
83
+ "async () => await codemode.add({ a: 3, b: 4 })",
84
+ fns
85
+ );
86
+
87
+ expect(result.result).toBe(7);
88
+ expect(add).toHaveBeenCalledWith({ a: 3, b: 4 });
89
+ });
90
+
91
+ it("should handle multiple sequential tool calls", async () => {
92
+ const getWeather = vi.fn(async () => ({ temp: 72 }));
93
+ const searchWeb = vi.fn(async (...args: unknown[]) => {
94
+ const input = args[0] as Record<string, unknown>;
95
+ return { results: [`news about ${input.query as string}`] };
96
+ });
97
+ const fns: ToolFns = { getWeather, searchWeb };
98
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
99
+
100
+ const code = `async () => {
101
+ const weather = await codemode.getWeather({});
102
+ const news = await codemode.searchWeb({ query: "temp " + weather.temp });
103
+ return { weather, news };
104
+ }`;
105
+
106
+ const result = await executor.execute(code, fns);
107
+ expect(result.result).toEqual({
108
+ weather: { temp: 72 },
109
+ news: { results: ["news about temp 72"] }
110
+ });
111
+ expect(getWeather).toHaveBeenCalledTimes(1);
112
+ expect(searchWeb).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("should return error when code throws", async () => {
116
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
117
+
118
+ const result = await executor.execute(
119
+ 'async () => { throw new Error("boom"); }',
120
+ {}
121
+ );
122
+ expect(result.error).toBe("boom");
123
+ });
124
+
125
+ it("should return error when tool function throws", async () => {
126
+ const fail = vi.fn(async () => {
127
+ throw new Error("tool error");
128
+ });
129
+ const fns: ToolFns = { fail };
130
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
131
+
132
+ const result = await executor.execute(
133
+ "async () => await codemode.fail({})",
134
+ fns
135
+ );
136
+ expect(result.error).toBe("tool error");
137
+ });
138
+
139
+ it("should handle concurrent tool calls via Promise.all", async () => {
140
+ const fns: ToolFns = {
141
+ slow: async (...args: unknown[]) => {
142
+ const input = args[0] as Record<string, unknown>;
143
+ return { id: input.id as number };
144
+ }
145
+ };
146
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
147
+
148
+ const code = `async () => {
149
+ const [a, b, c] = await Promise.all([
150
+ codemode.slow({ id: 1 }),
151
+ codemode.slow({ id: 2 }),
152
+ codemode.slow({ id: 3 })
153
+ ]);
154
+ return [a, b, c];
155
+ }`;
156
+
157
+ const result = await executor.execute(code, fns);
158
+ expect(result.result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
159
+ });
160
+
161
+ it("should capture console.log output", async () => {
162
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
163
+
164
+ const result = await executor.execute(
165
+ 'async () => { console.log("hello"); console.warn("careful"); return "done"; }',
166
+ {}
167
+ );
168
+
169
+ expect(result.result).toBe("done");
170
+ expect(result.logs).toContain("hello");
171
+ expect(result.logs).toContain("[warn] careful");
172
+ });
173
+
174
+ it("should handle code containing backticks and template literals", async () => {
175
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
176
+
177
+ const result = await executor.execute(
178
+ 'async () => { return `hello ${"world"}`; }',
179
+ {}
180
+ );
181
+
182
+ expect(result.result).toBe("hello world");
183
+ });
184
+
185
+ it("should block external fetch by default (globalOutbound: null)", async () => {
186
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
187
+
188
+ const result = await executor.execute(
189
+ 'async () => { const r = await fetch("https://example.com"); return r.status; }',
190
+ {}
191
+ );
192
+
193
+ // fetch should fail because globalOutbound defaults to null
194
+ expect(result.error).toBeDefined();
195
+ });
196
+
197
+ it("should preserve closures in tool functions", async () => {
198
+ const secret = "api-key-123";
199
+ const fns: ToolFns = {
200
+ getSecret: async () => ({ key: secret })
201
+ };
202
+ const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
203
+
204
+ const result = await executor.execute(
205
+ "async () => await codemode.getSecret({})",
206
+ fns
207
+ );
208
+ expect(result.result).toEqual({ key: "api-key-123" });
209
+ });
210
+
211
+ it("should include timeout in execution", async () => {
212
+ const executor = new DynamicWorkerExecutor({
213
+ loader: env.LOADER,
214
+ timeout: 100
215
+ });
216
+
217
+ const result = await executor.execute(
218
+ "async () => { await new Promise(r => setTimeout(r, 5000)); return 'done'; }",
219
+ {}
220
+ );
221
+
222
+ expect(result.error).toContain("timed out");
223
+ });
224
+ });