From c1bf808d3b78d02a5fac7ddef6d7382b285bd37d Mon Sep 17 00:00:00 2001 From: Christian Glassiognon <63924603+heyglassy@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:08:34 -0700 Subject: [PATCH 1/3] Refactor dynamic worker error transport with Effect causes Replace the dynamic worker runtime's string-only error transport with a structured internal envelope derived from Effect causes. Preserve the external execution contract and user-facing error strings while preventing object-shaped failures from collapsing to [object Object]. Re-throw structured tool failures through the worker boundary, render plain objects as JSON in final output, and add workerd-backed tests covering tagged failures, object failures, defects, interruptions, and execution-time thrown objects. Verified with bun x vitest run in packages/kernel/runtime-dynamic-worker. --- .../src/executor.test.ts | 4 +- .../runtime-dynamic-worker/src/executor.ts | 231 +++++++++++++----- .../src/invocation.test.ts | 121 +++++++-- .../src/module-template.ts | 52 +++- 4 files changed, 324 insertions(+), 84 deletions(-) diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.test.ts b/packages/kernel/runtime-dynamic-worker/src/executor.test.ts index 74342b8f9..d3db819be 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.test.ts @@ -43,6 +43,8 @@ describe("buildExecutorModule", () => { it("catches errors and returns them", () => { const module = buildExecutorModule("async () => 42", 5000); expect(module).toContain("catch (err)"); - expect(module).toContain("error: err.message"); + expect(module).toContain("const __serializeThrownError = (err) =>"); + expect(module).toContain("if (!data.ok)"); + expect(module).toContain("error: __serializeThrownError(err)"); }); }); diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.ts b/packages/kernel/runtime-dynamic-worker/src/executor.ts index 9c2f3542b..b7f6410ac 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.ts @@ -2,13 +2,14 @@ * DynamicWorkerExecutor — runs sandboxed code in an isolated Cloudflare * Worker via the WorkerLoader binding. * - * Tool calls are dispatched over Workers RPC: the host creates a + * Tool calls are dispatched over Workers RPC: the host creates a * `ToolDispatcher` (an `RpcTarget`) that bridges back to the - * `SandboxToolInvoker` from codemode-core, and passes it to the dynamic - * worker's `evaluate()` entrypoint. + * `SandboxToolInvoker` from codemode-core, and passes it to the + * dynamic worker's `evaluate()` entrypoint. */ import { RpcTarget } from "cloudflare:workers"; +import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -17,55 +18,169 @@ import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/ import { normalizeCode } from "./normalize"; import { buildExecutorModule } from "./module-template"; -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - export class DynamicWorkerExecutionError extends Data.TaggedError("DynamicWorkerExecutionError")<{ readonly message: string; }> {} -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- - export type DynamicWorkerExecutorOptions = { readonly loader: WorkerLoader; - /** - * Timeout in milliseconds for code execution. Defaults to 5 minutes. - */ readonly timeoutMs?: number; - /** - * Controls outbound network access from sandboxed code. - * - `null` (default): `fetch()` and `connect()` throw — fully isolated. - * - `undefined`: inherits parent Worker's network access. - * - A `Fetcher`: all outbound requests route through this handler. - */ readonly globalOutbound?: Fetcher | null; - /** - * Additional modules to make available in the sandbox. - * Keys are module specifiers, values are module source code. - * The key `"executor.js"` is reserved. - */ readonly modules?: Record; }; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +export type SerializedWorkerErrorValue = unknown; + +export type SerializedWorkerError = { + readonly kind: "fail" | "die" | "interrupt" | "mixed" | "unknown"; + readonly message: string; + readonly primary: SerializedWorkerErrorValue | null; + readonly failures: ReadonlyArray; + readonly defects: ReadonlyArray; + readonly interrupted: boolean; +}; + +type WorkerRpcSuccess = { + readonly ok: true; + readonly result: unknown; +}; + +type WorkerRpcFailure = { + readonly ok: false; + readonly error: SerializedWorkerError; +}; + +type WorkerRpcResponse = WorkerRpcSuccess | WorkerRpcFailure; const DEFAULT_TIMEOUT_MS = 5 * 60_000; const ENTRY_MODULE = "executor.js"; -// --------------------------------------------------------------------------- -// ToolDispatcher — bridges RPC calls back to SandboxToolInvoker -// --------------------------------------------------------------------------- +const normalizeErrorObject = (error: Error) => ({ + __type: "Error" as const, + name: error.name, + message: error.message, + ...(typeof error.stack === "string" && error.stack.length > 0 ? { stack: error.stack } : {}), +}); + +const isNormalizedErrorObject = ( + value: unknown, +): value is { readonly __type: "Error"; readonly message: string } => + typeof value === "object" && + value !== null && + "__type" in value && + value.__type === "Error" && + "message" in value && + typeof value.message === "string"; + +const serializeWorkerErrorValue = (value: unknown): SerializedWorkerErrorValue => { + if (value instanceof Error) { + return normalizeErrorObject(value); + } + + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + try { + return JSON.parse(JSON.stringify(value)) as SerializedWorkerErrorValue; + } catch { + return String(value); + } +}; + +const renderTransportMessage = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + + if (isNormalizedErrorObject(value)) { + return value.message; + } + + if (value instanceof Error) { + return value.message; + } + + if (typeof value === "object" && value !== null && "message" in value && typeof value.message === "string") { + return value.message; + } + + if (typeof value === "object" && value !== null) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + if (typeof value === "undefined") { + return "Unknown error"; + } + + return String(value); +}; + +export const serializeWorkerCause = (cause: Cause.Cause): SerializedWorkerError => { + const failures = Array.from(Cause.failures(cause), serializeWorkerErrorValue); + const defects = Array.from(Cause.defects(cause), serializeWorkerErrorValue); + const interrupted = Cause.isInterrupted(cause); + const primary = failures[0] ?? defects[0] ?? null; + const kind = + failures.length > 0 && defects.length > 0 + ? "mixed" + : failures.length > 0 + ? "fail" + : defects.length > 0 + ? "die" + : interrupted + ? "interrupt" + : "unknown"; + + return { + kind, + message: + primary !== null + ? renderTransportMessage(primary) + : interrupted + ? "Interrupted" + : "Unknown error", + primary, + failures, + defects, + interrupted, + }; +}; + +export const renderWorkerError = (error: SerializedWorkerError): string => { + if (isNormalizedErrorObject(error.primary)) { + return error.primary.message; + } + + if (typeof error.primary === "string") { + return error.primary; + } + + if (typeof error.primary === "object" && error.primary !== null) { + try { + return JSON.stringify(error.primary); + } catch { + return error.message; + } + } + + return error.message; +}; + +const encodeWorkerRpcResponse = (response: WorkerRpcResponse): string => JSON.stringify(response); + +export const decodeWorkerRpcResponse = (raw: string): WorkerRpcResponse => + JSON.parse(raw) as WorkerRpcResponse; -/** - * An `RpcTarget` passed to the dynamic Worker so that sandboxed code can - * invoke tools on the host. The dynamic worker calls - * `__dispatcher.call(path, argsJson)` over Workers RPC. - */ export class ToolDispatcher extends RpcTarget { readonly #invoker: SandboxToolInvoker; @@ -79,28 +194,25 @@ export class ToolDispatcher extends RpcTarget { return Effect.runPromise( this.#invoker.invoke({ path, args }).pipe( - Effect.map((value) => JSON.stringify({ result: value })), + Effect.map( + (value): WorkerRpcResponse => ({ + ok: true, + result: value, + }), + ), + Effect.sandbox, Effect.catchAll((cause) => - Effect.succeed( - JSON.stringify({ - error: - cause instanceof Error - ? cause.message - : typeof cause === "object" && cause !== null && "message" in cause - ? String((cause as { message: unknown }).message) - : String(cause), - }), - ), + Effect.succeed({ + ok: false, + error: serializeWorkerCause(cause), + }), ), + Effect.map(encodeWorkerRpcResponse), ), ); } } -// --------------------------------------------------------------------------- -// Evaluate -// --------------------------------------------------------------------------- - const evaluate = async ( options: DynamicWorkerExecutorOptions, code: string, @@ -128,24 +240,21 @@ const evaluate = async ( const entrypoint = worker.getEntrypoint() as unknown as { evaluate(dispatcher: ToolDispatcher): Promise<{ result: unknown; - error?: string; + error?: SerializedWorkerError; logs?: string[]; }>; }; const response = await entrypoint.evaluate(dispatcher); + const error = response.error ? renderWorkerError(response.error) : undefined; return { - result: response.error ? null : response.result, - error: response.error, + result: error ? null : response.result, + error, logs: response.logs, }; }; -// --------------------------------------------------------------------------- -// Effect wrapper -// --------------------------------------------------------------------------- - const runInDynamicWorker = ( options: DynamicWorkerExecutorOptions, code: string, @@ -155,14 +264,10 @@ const runInDynamicWorker = ( try: () => evaluate(options, code, toolInvoker), catch: (cause) => new DynamicWorkerExecutionError({ - message: cause instanceof Error ? cause.message : String(cause), + message: renderTransportMessage(serializeWorkerErrorValue(cause)), }), }); -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - export const makeDynamicWorkerExecutor = (options: DynamicWorkerExecutorOptions): CodeExecutor => ({ execute: (code: string, toolInvoker: SandboxToolInvoker) => runInDynamicWorker(options, code, toolInvoker), diff --git a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts index 0699ab536..28753f446 100644 --- a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect } from "vitest"; import { env } from "cloudflare:workers"; +import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as FiberId from "effect/FiberId"; import type { SandboxToolInvoker } from "@executor/codemode-core"; -import { ToolDispatcher } from "./executor"; -import { makeDynamicWorkerExecutor } from "./executor"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +import { + ToolDispatcher, + decodeWorkerRpcResponse, + makeDynamicWorkerExecutor, + renderWorkerError, + serializeWorkerCause, +} from "./executor"; class TestToolError extends Data.TaggedError("TestToolError")<{ readonly message: string; @@ -24,24 +27,61 @@ const failingInvoker = (message: string): SandboxToolInvoker => ({ invoke: () => Effect.fail(new TestToolError({ message })), }); -// --------------------------------------------------------------------------- -// ToolDispatcher -// --------------------------------------------------------------------------- - describe("ToolDispatcher", () => { - it("returns JSON result on successful tool call", async () => { + it("returns a success envelope on successful tool call", async () => { const invoker = makeInvoker(({ args }) => args); const dispatcher = new ToolDispatcher(invoker); const result = await dispatcher.call("test.tool", '{"key":"value"}'); - expect(JSON.parse(result)).toEqual({ result: { key: "value" } }); + expect(decodeWorkerRpcResponse(result)).toEqual({ ok: true, result: { key: "value" } }); }); - it("returns JSON error when tool invocation fails", async () => { + it("serializes tagged failures into a structured error envelope", async () => { const dispatcher = new ToolDispatcher(failingInvoker("tool broke")); const result = await dispatcher.call("broken.tool", "{}"); - expect(JSON.parse(result)).toEqual({ error: "tool broke" }); + expect(decodeWorkerRpcResponse(result)).toMatchObject({ + ok: false, + error: { + kind: "fail", + message: "tool broke", + primary: { __type: "Error", name: "TestToolError", message: "tool broke" }, + failures: [{ __type: "Error", name: "TestToolError", message: "tool broke" }], + defects: [], + interrupted: false, + }, + }); + }); + + it("serializes object-shaped tool errors without collapsing them", async () => { + const dispatcher = new ToolDispatcher({ + invoke: () => + Effect.fail({ + code: "forbidden", + detail: "missing team access", + }), + }); + + const result = await dispatcher.call("broken.tool", "{}"); + expect(decodeWorkerRpcResponse(result)).toEqual({ + ok: false, + error: { + kind: "fail", + message: '{"code":"forbidden","detail":"missing team access"}', + primary: { + code: "forbidden", + detail: "missing team access", + }, + failures: [ + { + code: "forbidden", + detail: "missing team access", + }, + ], + defects: [], + interrupted: false, + }, + }); }); it("handles undefined args", async () => { @@ -49,7 +89,7 @@ describe("ToolDispatcher", () => { const dispatcher = new ToolDispatcher(invoker); const result = await dispatcher.call("test.tool", ""); - expect(JSON.parse(result)).toEqual({ result: undefined }); + expect(decodeWorkerRpcResponse(result)).toEqual({ ok: true, result: undefined }); }); it("passes the tool path correctly", async () => { @@ -65,9 +105,21 @@ describe("ToolDispatcher", () => { }); }); -// --------------------------------------------------------------------------- -// Full execution via makeDynamicWorkerExecutor -// --------------------------------------------------------------------------- +describe("serializeWorkerCause", () => { + it("captures defects", () => { + const serialized = serializeWorkerCause(Cause.die({ defect: true })); + expect(serialized.kind).toBe("die"); + expect(serialized.defects).toEqual([{ defect: true }]); + expect(serialized.failures).toEqual([]); + }); + + it("captures interruptions", () => { + const serialized = serializeWorkerCause(Cause.interrupt(FiberId.none)); + expect(serialized.kind).toBe("interrupt"); + expect(serialized.interrupted).toBe(true); + expect(renderWorkerError(serialized)).toBe("Interrupted"); + }); +}); describe("makeDynamicWorkerExecutor", () => { const loader = (env as { LOADER: WorkerLoader }).LOADER; @@ -122,6 +174,21 @@ describe("makeDynamicWorkerExecutor", () => { expect(result.result).toBeNull(); }); + it("serializes thrown objects into the user-facing error text", async () => { + const executor = makeDynamicWorkerExecutor({ loader }); + const invoker = makeInvoker(() => null); + + const result = await Effect.runPromise( + executor.execute( + 'async () => { throw { code: "bad_request", detail: "team missing" }; }', + invoker, + ), + ); + + expect(result.error).toBe('{"code":"bad_request","detail":"team missing"}'); + expect(result.result).toBeNull(); + }); + it("invokes tools via the proxy and returns results", async () => { const executor = makeDynamicWorkerExecutor({ loader }); const invoker = makeInvoker(({ path, args }) => { @@ -154,6 +221,24 @@ describe("makeDynamicWorkerExecutor", () => { expect(result.error).toBe("not authorized"); }); + it("surfaces object-shaped tool errors in execution result", async () => { + const executor = makeDynamicWorkerExecutor({ loader }); + const invoker = { + invoke: () => + Effect.fail({ + code: "forbidden", + detail: "missing team access", + }), + } satisfies SandboxToolInvoker; + + const result = await Effect.runPromise( + executor.execute("async () => { return await tools.secret.read({}); }", invoker), + ); + + expect(result.error).toBe('{"code":"forbidden","detail":"missing team access"}'); + expect(result.result).toBeNull(); + }); + it("handles multiple tool calls in sequence", async () => { const executor = makeDynamicWorkerExecutor({ loader }); const invoker = makeInvoker(({ path }) => { diff --git a/packages/kernel/runtime-dynamic-worker/src/module-template.ts b/packages/kernel/runtime-dynamic-worker/src/module-template.ts index 2c6902290..89b041c03 100644 --- a/packages/kernel/runtime-dynamic-worker/src/module-template.ts +++ b/packages/kernel/runtime-dynamic-worker/src/module-template.ts @@ -18,6 +18,52 @@ export const buildExecutorModule = (normalizedCode: string, timeoutMs: number): ' 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(" ")); };', + " const __serializeErrorValue = (value) => {", + " if (value instanceof Error) {", + " return {", + " __type: 'Error',", + " name: value.name,", + " message: value.message,", + " ...(typeof value.stack === 'string' && value.stack.length > 0 ? { stack: value.stack } : {}),", + " };", + " }", + " if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {", + " return value;", + " }", + " try {", + " return JSON.parse(JSON.stringify(value));", + " } catch {", + " return String(value);", + " }", + " };", + " const __renderMessage = (value) => {", + " if (typeof value === 'string') return value;", + " if (value && typeof value === 'object' && value.__type === 'Error' && typeof value.message === 'string') {", + " return value.message;", + " }", + " if (value instanceof Error) return value.message;", + " if (value && typeof value === 'object' && typeof value.message === 'string') return value.message;", + " if (typeof value === 'undefined') return 'Unknown error';", + " if (value && typeof value === 'object') {", + " try {", + " return JSON.stringify(value);", + " } catch {", + " return String(value);", + " }", + " }", + " return String(value);", + " };", + " const __serializeThrownError = (err) => {", + " const primary = __serializeErrorValue(err);", + " return {", + " kind: 'unknown',", + " message: __renderMessage(primary),", + " primary,", + " failures: [],", + " defects: [],", + " interrupted: false,", + " };", + " };", "", " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", " get(_target, prop) {", @@ -29,7 +75,9 @@ export const buildExecutorModule = (normalizedCode: string, timeoutMs: number): " if (!toolPath) throw new Error('Tool path missing in invocation');", " return __dispatcher.call(toolPath, JSON.stringify(args[0] ?? {})).then((raw) => {", " const data = JSON.parse(raw);", - " if (data.error) throw new Error(data.error);", + " if (!data.ok) {", + " throw (data.error && data.error.primary !== null ? data.error.primary : data.error.message);", + " }", " return data.result;", " });", " },", @@ -45,7 +93,7 @@ export const buildExecutorModule = (normalizedCode: string, timeoutMs: number): " ]);", " return { result, logs: __logs };", " } catch (err) {", - " return { result: undefined, error: err.message ?? String(err), logs: __logs };", + " return { result: undefined, error: __serializeThrownError(err), logs: __logs };", " }", " }", "}", From c2c17ff3cb625253c81d97612e9bff0cf8abcf05 Mon Sep 17 00:00:00 2001 From: Christian Glassiognon <63924603+heyglassy@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:10:03 -0700 Subject: [PATCH 2/3] From 4ed92dfa7363ab8d2bba74ac11c5ee7893e6a17f Mon Sep 17 00:00:00 2001 From: Christian Glassiognon <63924603+heyglassy@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:13:22 -0700 Subject: [PATCH 3/3] Refactor dynamic worker error transport with Effect causes Replace the dynamic worker runtime's string-only error transport with a structured internal envelope derived from Effect causes. Preserve the external execution contract and user-facing error strings while preventing object-shaped failures from collapsing to [object Object]. Re-throw structured tool failures through the worker boundary, render plain objects as JSON in final output, restore the original structural comments in the runtime module, and keep the workerd-backed regression coverage for tagged failures, object failures, defects, interruptions, and execution-time thrown objects. Verified with bun x vitest run in packages/kernel/runtime-dynamic-worker. --- .../runtime-dynamic-worker/src/executor.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.ts b/packages/kernel/runtime-dynamic-worker/src/executor.ts index b7f6410ac..598f374e9 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.ts @@ -18,14 +18,36 @@ import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/ import { normalizeCode } from "./normalize"; import { buildExecutorModule } from "./module-template"; +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + export class DynamicWorkerExecutionError extends Data.TaggedError("DynamicWorkerExecutionError")<{ readonly message: string; }> {} +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + export type DynamicWorkerExecutorOptions = { readonly loader: WorkerLoader; + /** + * Timeout in milliseconds for code execution. Defaults to 5 minutes. + */ readonly timeoutMs?: number; + /** + * Controls outbound network access from sandboxed code. + * - `null` (default): `fetch()` and `connect()` throw — fully isolated. + * - `undefined`: inherits parent Worker's network access. + * - A `Fetcher`: all outbound requests route through this handler. + */ readonly globalOutbound?: Fetcher | null; + /** + * Additional modules to make available in the sandbox. + * Keys are module specifiers, values are module source code. + * The key `"executor.js"` is reserved. + */ readonly modules?: Record; }; @@ -52,6 +74,10 @@ type WorkerRpcFailure = { type WorkerRpcResponse = WorkerRpcSuccess | WorkerRpcFailure; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + const DEFAULT_TIMEOUT_MS = 5 * 60_000; const ENTRY_MODULE = "executor.js"; @@ -181,6 +207,15 @@ const encodeWorkerRpcResponse = (response: WorkerRpcResponse): string => JSON.st export const decodeWorkerRpcResponse = (raw: string): WorkerRpcResponse => JSON.parse(raw) as WorkerRpcResponse; +// --------------------------------------------------------------------------- +// ToolDispatcher — bridges RPC calls back to SandboxToolInvoker +// --------------------------------------------------------------------------- + +/** + * An `RpcTarget` passed to the dynamic Worker so that sandboxed code can + * invoke tools on the host. The dynamic worker calls + * `__dispatcher.call(path, argsJson)` over Workers RPC. + */ export class ToolDispatcher extends RpcTarget { readonly #invoker: SandboxToolInvoker; @@ -213,6 +248,10 @@ export class ToolDispatcher extends RpcTarget { } } +// --------------------------------------------------------------------------- +// Evaluate +// --------------------------------------------------------------------------- + const evaluate = async ( options: DynamicWorkerExecutorOptions, code: string, @@ -255,6 +294,10 @@ const evaluate = async ( }; }; +// --------------------------------------------------------------------------- +// Effect wrapper +// --------------------------------------------------------------------------- + const runInDynamicWorker = ( options: DynamicWorkerExecutorOptions, code: string, @@ -268,6 +311,10 @@ const runInDynamicWorker = ( }), }); +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + export const makeDynamicWorkerExecutor = (options: DynamicWorkerExecutorOptions): CodeExecutor => ({ execute: (code: string, toolInvoker: SandboxToolInvoker) => runInDynamicWorker(options, code, toolInvoker),