/** * Executor interface and DynamicWorkerExecutor implementation. * * The Executor interface is the core abstraction — implement it to run * LLM-generated code in any sandbox (Workers, QuickJS, Node VM, etc.). */ import { RpcTarget } from "cloudflare:workers"; import { normalizeCode } from "./normalize"; import { sanitizeToolName } from "./utils"; import type { ToolDescriptors } from "./tool-types"; import type { ToolSet } from "ai"; export interface ExecuteResult { result: unknown; error?: string; logs?: string[]; } // ── ToolProvider ────────────────────────────────────────────────────── /** * A minimal tool record — just a description and an execute function. * Use this for providers that supply their own `types` and don't need * schema-based type generation (e.g. stateTools). */ export type SimpleToolRecord = Record< string, { description?: string; execute: (args: unknown) => Promise } >; /** * All tool record types accepted by a ToolProvider. */ export type ToolProviderTools = ToolDescriptors | ToolSet | SimpleToolRecord; /** * A ToolProvider contributes tools to the codemode sandbox under a namespace. * * Each provider's tools are accessible as `name.toolName()` in sandbox code. * If `name` is omitted, tools are exposed under the default `codemode.*` namespace. * * @example Multiple providers with different namespaces * ```ts * createCodeTool({ * tools: [ * { name: "github", tools: githubTools }, * { name: "shell", tools: shellTools }, * { tools: aiTools }, // default "codemode" namespace * ], * executor, * }); * // sandbox: github.listIssues(), shell.exec(), codemode.search() * ``` */ export interface ToolProvider { /** Namespace prefix in the sandbox (e.g. "state", "mcp"). Defaults to "codemode". */ name?: string; /** Tools exposed as `namespace.toolName()` in the sandbox. */ tools: ToolProviderTools; /** Type declarations for the LLM. Auto-generated from `tools` if omitted. */ types?: string; /** * When true, tools accept positional args instead of a single object arg. * The sandbox proxy uses `(...args)` and the dispatcher spreads the args array. * * Default tools use single-object args: `codemode.search({ query: "test" })` * Positional tools use normal args: `state.readFile("/path")` */ positionalArgs?: boolean; } // ── ResolvedProvider ────────────────────────────────────────────────── /** * Internal resolved form of a ToolProvider, ready for execution. * The tool functions have been extracted and keyed by sanitized name. */ export interface ResolvedProvider { name: string; fns: Record Promise>; positionalArgs?: boolean; } // ── Executor ────────────────────────────────────────────────────────── /** * An executor runs LLM-generated code in a sandbox, making the provided * tool functions callable under their namespace inside the sandbox. * * Implementations should never throw — errors are returned in `ExecuteResult.error`. * * @param code - The code to execute in the sandbox. * @param providersOrFns - An array of `ResolvedProvider` (preferred), or a * plain `Record` for backwards compatibility (deprecated — will * be removed in the next major version). */ export interface Executor { execute( code: string, providersOrFns: | ResolvedProvider[] | Record Promise> ): Promise; } // ── ToolDispatcher ──────────────────────────────────────────────────── /** * An RpcTarget that dispatches tool calls from the sandboxed Worker * back to the host. Passed via Workers RPC to the dynamic Worker's * evaluate() method — no globalOutbound or Fetcher bindings needed. */ export class ToolDispatcher extends RpcTarget { #fns: Record Promise>; #positionalArgs: boolean; constructor( fns: Record Promise>, positionalArgs = false ) { super(); this.#fns = fns; this.#positionalArgs = positionalArgs; } async call(name: string, argsJson: string): Promise { const fn = this.#fns[name]; if (!fn) { return JSON.stringify({ error: `Tool "${name}" not found` }); } try { if (this.#positionalArgs) { const args = argsJson ? JSON.parse(argsJson) : []; const result = await fn(...(Array.isArray(args) ? args : [args])); return JSON.stringify({ result }); } const args = argsJson ? JSON.parse(argsJson) : {}; const result = await fn(args); return JSON.stringify({ result }); } catch (err) { return JSON.stringify({ error: err instanceof Error ? err.message : String(err) }); } } } // ── DynamicWorkerExecutor ───────────────────────────────────────────── export interface DynamicWorkerExecutorOptions { loader: WorkerLoader; /** * Timeout in milliseconds for code execution. Defaults to 30000 (30s). */ timeout?: number; /** * Controls outbound network access from sandboxed code. * - `null` (default): fetch() and connect() throw — sandbox is fully isolated. * - `undefined`: inherits parent Worker's network access (full internet). * - A `Fetcher`: all outbound requests route through this handler. */ globalOutbound?: Fetcher | null; /** * Additional modules to make available in the sandbox. * Keys are module specifiers (e.g. `"mylib.js"`), values are module source code. * * Note: the key `"executor.js"` is reserved and will be ignored if provided. */ modules?: Record; } /** * Executes code in an isolated Cloudflare Worker via WorkerLoader. * Tool calls are dispatched via Workers RPC — the host passes * ToolDispatchers (one per namespace) to the Worker's evaluate() method. * * External fetch() and connect() are blocked by default via * `globalOutbound: null` (runtime-enforced). Pass a Fetcher to * `globalOutbound` to allow controlled outbound access. * * @example * ```ts * const result = await executor.execute(code, [ * { name: "codemode", fns: { search: searchFn } }, * { name: "state", fns: { readFile: readFileFn } }, * ]); * // sandbox has both codemode.search() and state.readFile() * ``` */ export class DynamicWorkerExecutor implements Executor { #loader: WorkerLoader; #timeout: number; #globalOutbound: Fetcher | null; #modules: Record; constructor(options: DynamicWorkerExecutorOptions) { this.#loader = options.loader; this.#timeout = options.timeout ?? 30000; this.#globalOutbound = options.globalOutbound ?? null; const { "executor.js": _, ...safeModules } = options.modules ?? {}; this.#modules = safeModules; } async execute( code: string, providersOrFns: | ResolvedProvider[] | Record Promise> ): Promise { // Backwards compat: detect old `execute(code, fns)` signature. let providers: ResolvedProvider[]; if (!Array.isArray(providersOrFns)) { console.warn( "[@cloudflare/codemode] Passing raw fns to executor.execute() is deprecated. " + "Use ResolvedProvider[] instead. This will be removed in the next major version." ); providers = [{ name: "codemode", fns: providersOrFns }]; } else { providers = providersOrFns; } const normalized = normalizeCode(code); const timeoutMs = this.#timeout; // Validate provider names. const RESERVED_NAMES = new Set(["__dispatchers", "__logs"]); const VALID_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; const seenNames = new Set(); for (const provider of providers) { if (RESERVED_NAMES.has(provider.name)) { return { result: undefined, error: `Provider name "${provider.name}" is reserved` }; } if (!VALID_IDENT.test(provider.name)) { return { result: undefined, error: `Provider name "${provider.name}" is not a valid JavaScript identifier` }; } if (seenNames.has(provider.name)) { return { result: undefined, error: `Duplicate provider name "${provider.name}"` }; } seenNames.add(provider.name); } // Generate a Proxy global for each provider namespace. const proxyInits = providers.map((p) => { if (p.positionalArgs) { return ( ` const ${p.name} = new Proxy({}, {\n` + ` get: (_, toolName) => async (...args) => {\n` + ` const resJson = await __dispatchers.${p.name}.call(String(toolName), JSON.stringify(args));\n` + ` const data = JSON.parse(resJson);\n` + ` if (data.error) throw new Error(data.error);\n` + ` return data.result;\n` + ` }\n` + ` });` ); } return ( ` const ${p.name} = new Proxy({}, {\n` + ` get: (_, toolName) => async (args) => {\n` + ` const resJson = await __dispatchers.${p.name}.call(String(toolName), JSON.stringify(args ?? {}));\n` + ` const data = JSON.parse(resJson);\n` + ` if (data.error) throw new Error(data.error);\n` + ` return data.result;\n` + ` }\n` + ` });` ); }); const executorModule = [ 'import { WorkerEntrypoint } from "cloudflare:workers";', "", "export default class CodeExecutor extends WorkerEntrypoint {", " async evaluate(__dispatchers = {}) {", " const __logs = [];", ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };', ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };', ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };', ...proxyInits, "", " try {", " const result = await Promise.race([", " (" ] .concat([normalized]) .concat([ ")(),", ' new Promise((_, reject) => setTimeout(() => reject(new Error("Execution timed out")), ' + timeoutMs + "))", " ]);", " return { result, logs: __logs };", " } catch (err) {", " return { result: undefined, error: err.message, logs: __logs };", " }", " }", "}" ]) .join("\n"); // Build dispatcher map: { codemode: ToolDispatcher, state: ToolDispatcher, ... } // Sanitize fn keys so raw tool names (e.g. "github.list-issues") become // valid JS identifiers (e.g. "github_list_issues") on the proxy. const dispatchers: Record = {}; for (const provider of providers) { const sanitizedFns: Record< string, (...args: unknown[]) => Promise > = {}; for (const [name, fn] of Object.entries(provider.fns)) { sanitizedFns[sanitizeToolName(name)] = fn; } dispatchers[provider.name] = new ToolDispatcher( sanitizedFns, provider.positionalArgs ); } const worker = this.#loader.get(`codemode-${crypto.randomUUID()}`, () => ({ compatibilityDate: "2025-06-01", compatibilityFlags: ["nodejs_compat"], mainModule: "executor.js", modules: { ...this.#modules, "executor.js": executorModule }, globalOutbound: this.#globalOutbound })); const entrypoint = worker.getEntrypoint() as unknown as { evaluate(dispatchers: Record): Promise<{ result: unknown; error?: string; logs?: string[]; }>; }; const response = await entrypoint.evaluate(dispatchers); if (response.error) { return { result: undefined, error: response.error, logs: response.logs }; } return { result: response.result, logs: response.logs }; } }