diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index 999fdd4f..b6e7bb30 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -54,9 +54,11 @@ Updates from late child cleanup are ignored once the tool call has settled. This projection describes assigned tasks and lifecycle states; child tool and model output remain in the child trajectories. -Workflows require the native `isolated-vm` runtime. Registration is disabled when -it cannot load. Unit tests can inject a VM executor to check the host contract; -those tests do not validate native isolation. +Workflows run in QuickJS compiled to WebAssembly, a pure-JavaScript dependency +that needs no native build, so registration no longer depends on the runtime and +is disabled only by `enabled: false` or `STEP_DISABLE_WORKFLOW=1`. Unit tests can +inject a VM executor to check the host contract; those tests do not validate +isolation itself. ## Child fan-out and turn settlement diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index f46c67e9..d75152d3 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -21,16 +21,16 @@ The script can be run from any directory. Step keeps the caller's current workin ### Workflow runtime -Workflow execution requires the optional `isolated-vm` native addon to load under the selected Node runtime. A successful install with `--ignore-scripts` alone does not establish that the native addon is built. From the repository root, check that the addon can create an isolate: +Workflow scripts run in QuickJS compiled to WebAssembly (`src/features/workflow/vm.ts`). The engine ships with the package as a pure-JavaScript dependency, so it needs no native build step and behaves identically under Node and under the standalone executable — the `workflow` tool, `/workflows`, and `/ultraloop` register on every supported runtime. -```bash -cd packages/coding-agent -node --no-node-snapshot -e 'const vm = require("isolated-vm"); const isolate = new vm.Isolate({ memoryLimit: 16 }); console.log(isolate.createContextSync().evalSync("1 + 1")); isolate.dispose();' -``` +This replaced the `isolated-vm` native addon, which linked V8's C++ API directly and therefore could only load on a V8 host. The standalone executable is built with Bun and runs on JavaScriptCore, so that addon could never load there: workflows, and with them the ultraloop opt-in that shares the workflow registration gate, were silently missing from every released build while working in a source run on Node. + +Two constraints matter when editing the sandbox: -The expected result is `2`. If loading fails under Node, inspect the underlying error and install or rebuild the addon for that runtime before using workflows. +- Keep the `singlefile` QuickJS variant. The default `wasmfile` variant loads its `.wasm` from disk beside its own module, and that path does not exist inside a compiled executable's virtual filesystem. +- Release every handle before disposing the context, and dispose the context before the runtime. Otherwise QuickJS aborts the whole WebAssembly instance on `JS_FreeRuntime`, and because the module is cached process-wide that poisons every later run in the session. -The Bun standalone executable cannot host this V8 addon. It therefore does not register the `workflow` tool, `/workflows`, or `/ultraloop`. Suppressing the unsupported-runtime warning does not enable those features; use the Node source entry with a working addon when workflows are required. +Guest scripts have no access to `process`, `require`, `fetch`, the wall clock, or randomness, and run under a memory cap and a timeout. A single uninterrupted CPU burst longer than the timeout is terminated; time spent waiting on a host call is not counted against it. ## Forking / Rebranding diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 35e13dab..baebe106 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -68,8 +68,7 @@ } }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.9", - "isolated-vm": "6.0.1" + "@mariozechner/clipboard": "0.3.9" }, "devDependencies": { "@types/cross-spawn": "6.0.6", diff --git a/packages/coding-agent/src/features/workflow/index.ts b/packages/coding-agent/src/features/workflow/index.ts index 0db2ece2..7bb61629 100644 --- a/packages/coding-agent/src/features/workflow/index.ts +++ b/packages/coding-agent/src/features/workflow/index.ts @@ -31,12 +31,7 @@ export { workflowHash, } from "./journal.ts"; export { formatWorkflowStatus, listSavedWorkflows, listWorkflowRuns, WorkflowProgressStore } from "./progress.ts"; -export { - defaultWorkflowVmExecutor, - WorkflowRuntime, - WorkflowSchemaError, - workflowToolResult, -} from "./runtime.ts"; +export { WorkflowRuntime, WorkflowSchemaError, workflowToolResult } from "./runtime.ts"; export { validateWorkflowSchema } from "./schema.ts"; export { createStepWorkflowExtension, @@ -53,5 +48,4 @@ export { WORKFLOW_TOOL_PROFILES, } from "./tool-profile.ts"; export type * from "./types.ts"; -export { isIsolatedVmAvailable, loadIsolatedVm, runInIsolatedVm, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts"; -export { isQuickJsVmAvailable, runInQuickJs } from "./vm-quickjs.ts"; +export { runInQuickJs, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts"; diff --git a/packages/coding-agent/src/features/workflow/registration-gate.ts b/packages/coding-agent/src/features/workflow/registration-gate.ts index c82fbcb9..39f07a57 100644 --- a/packages/coding-agent/src/features/workflow/registration-gate.ts +++ b/packages/coding-agent/src/features/workflow/registration-gate.ts @@ -1,5 +1,3 @@ -import { isIsolatedVmAvailable, isIsolatedVmHostable } from "./vm.ts"; - function envFlag(value: string | undefined): boolean { const normalized = value?.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "on"; @@ -7,52 +5,32 @@ function envFlag(value: string | undefined): boolean { export type WorkflowRegistrationDecision = | { enabled: true } - | { enabled: false; reason: "not-enabled" | "disabled-by-env" | "vm-unavailable" }; - -/** - * Startup warning for the one refusal that contradicts the default-on - * registration: every other reason honors an explicit "off" or a runtime that - * has a working executor anyway. - */ -export const WORKFLOW_VM_UNAVAILABLE_WARNING = - "Workflow tools are unavailable this session: the isolated-vm native module failed to load. Rebuild or reinstall isolated-vm to restore the workflow tool, /workflows, and /ultraloop, or set STEP_DISABLE_WORKFLOW=1 to silence this warning."; + | { enabled: false; reason: "not-enabled" | "disabled-by-env" }; /** * Shared registration gate for the workflow tool and any extension layered on * top of it (e.g. ultraloop-opt-in). Consumers should call this instead of - * duplicating the predicate so the two gates cannot drift. Kept in its own - * leaf module so callers do not pull the full workflow runtime chain just to - * check the gate. Returns the refusal reason so the workflow extension can - * warn when the default-on registration degrades, instead of silently - * registering nothing. + * duplicating the predicate so the two gates cannot drift. Kept in its own leaf + * module so callers do not pull the full workflow runtime chain just to check + * the gate. * - * Registration is on by default, matching Claude Code: a registered tool is an - * environment capability, and USAGE consent stays gated per turn/session by - * the ultraloop opt-in. An embedder's `enabled: false` or - * STEP_DISABLE_WORKFLOW=1 turns registration off; STEP_ENABLE_WORKFLOW is no - * longer read. + * Registration is on by default and now depends only on explicit opt-outs. The + * sandbox is QuickJS compiled to WebAssembly (see `vm.ts`); it ships with the + * package and runs on every supported runtime, so no environment can take + * workflows away any more. The V8-native `isolated-vm` used to — silently, on + * every released executable, whose JavaScriptCore engine can never load it. * - * A runtime that cannot host isolated-vm is not a refusal. The V8-native addon - * never loads on the shipped executable's JavaScriptCore engine, so that host - * runs workflows through the bundled QuickJS WebAssembly executor instead (see - * `vm-quickjs.ts`) and registers normally. Only a V8 host whose addon failed to - * load is a real, fixable gap — that one warns. + * A registered tool is only an environment capability; USAGE consent stays gated + * per turn/session by the ultraloop opt-in. An embedder's `enabled: false` or + * STEP_DISABLE_WORKFLOW=1 turns registration off; STEP_ENABLE_WORKFLOW is not + * read. */ -export function resolveWorkflowRegistration( - options: { enabled?: boolean; vmExecutor?: unknown } = {}, - vmAvailable: boolean = isIsolatedVmAvailable(), - vmHostable: boolean = isIsolatedVmHostable(), -): WorkflowRegistrationDecision { +export function resolveWorkflowRegistration(options: { enabled?: boolean } = {}): WorkflowRegistrationDecision { if (envFlag(process.env.STEP_DISABLE_WORKFLOW)) return { enabled: false, reason: "disabled-by-env" }; if (options.enabled === false) return { enabled: false, reason: "not-enabled" }; - if (!vmAvailable && !options.vmExecutor) { - // Non-V8 host: QuickJS stands in for isolated-vm, so workflows are available. - if (!vmHostable) return { enabled: true }; - return { enabled: false, reason: "vm-unavailable" }; - } return { enabled: true }; } -export function isWorkflowRegistrationEnabled(options: { enabled?: boolean; vmExecutor?: unknown } = {}): boolean { +export function isWorkflowRegistrationEnabled(options: { enabled?: boolean } = {}): boolean { return resolveWorkflowRegistration(options).enabled; } diff --git a/packages/coding-agent/src/features/workflow/runtime.ts b/packages/coding-agent/src/features/workflow/runtime.ts index 83d9ba9a..ac070d58 100644 --- a/packages/coding-agent/src/features/workflow/runtime.ts +++ b/packages/coding-agent/src/features/workflow/runtime.ts @@ -49,14 +49,7 @@ import type { WorkflowUsage, } from "./types.ts"; import { emptyWorkflowUsage, mergeWorkflowUsage, workflowUsageTokens } from "./types.ts"; -import { - isIsolatedVmAvailable, - runInIsolatedVm, - type WorkflowVmHost, - type WorkflowVmOptions, - type WorkflowVmResult, -} from "./vm.ts"; -import { runInQuickJs } from "./vm-quickjs.ts"; +import { runInQuickJs, type WorkflowVmHost, type WorkflowVmOptions, type WorkflowVmResult } from "./vm.ts"; const DEFAULT_MAX_ITERATIONS = 20; const MAX_MAX_ITERATIONS = 100; @@ -64,17 +57,6 @@ const DEFAULT_STAGNATION_LIMIT = 3; const DEFAULT_AGENT_TIMEOUT_MS = 30 * 60 * 1_000; const MAX_AGENT_TIMEOUT_MS = 60 * 60 * 1_000; -/** - * Pick the sandbox for this host. isolated-vm is a V8-native addon, so it is - * absent on the shipped executable's JavaScriptCore engine; QuickJS compiled to - * WebAssembly runs anywhere and stands in there. A V8 host with the addon - * installed keeps using it, so nothing changes for a source/Node run. An - * explicit `vmExecutor` still wins over both. - */ -export function defaultWorkflowVmExecutor(): NonNullable { - return isIsolatedVmAvailable() ? runInIsolatedVm : runInQuickJs; -} - export interface WorkflowRuntimeOptions { cwd: string; runId: string; @@ -159,7 +141,7 @@ export class WorkflowRuntime { this.signal = options.signal; this.nestedWorkflow = options.nestedWorkflow; this.now = options.now ?? Date.now; - this.vmExecutor = options.vmExecutor ?? defaultWorkflowVmExecutor(); + this.vmExecutor = options.vmExecutor ?? runInQuickJs; const startedAt = this.readNow(); this.startedAt = startedAt; const initial: WorkflowProgress = { diff --git a/packages/coding-agent/src/features/workflow/step-workflow.ts b/packages/coding-agent/src/features/workflow/step-workflow.ts index abaa8410..3a01da49 100644 --- a/packages/coding-agent/src/features/workflow/step-workflow.ts +++ b/packages/coding-agent/src/features/workflow/step-workflow.ts @@ -12,7 +12,7 @@ import type { StepTelemetryReporter } from "../../step/telemetry.ts"; import { createDefaultWorkflowAgentRunner } from "./agent-runner.ts"; import { createWorkflowRunPaths, newWorkflowRunId, resolveWorkflowRoot, WorkflowJournal } from "./journal.ts"; import { formatWorkflowStatus, listSavedWorkflows, listWorkflowRuns } from "./progress.ts"; -import { resolveWorkflowRegistration, WORKFLOW_VM_UNAVAILABLE_WARNING } from "./registration-gate.ts"; +import { resolveWorkflowRegistration } from "./registration-gate.ts"; import { renderWorkflowCall, renderWorkflowResult, @@ -23,7 +23,7 @@ import { WorkflowRuntime, workflowToolResult } from "./runtime.ts"; import { isWorkflowPathInside } from "./tool-profile.ts"; import type { WorkflowAgentRunner, WorkflowProgress, WorkflowRunResult } from "./types.ts"; import type { UltraloopTurnState } from "./ultraloop-opt-in.ts"; -import { type runInIsolatedVm, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts"; +import { type runInQuickJs, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts"; export const WorkflowParams = Type.Object({ script: Type.Optional(Type.String({ description: "Inline JavaScript workflow script" })), @@ -51,7 +51,7 @@ export interface StepWorkflowExtensionOptions { telemetry?: StepTelemetryReporter; enabled?: boolean; runner?: WorkflowAgentRunner; - vmExecutor?: typeof runInIsolatedVm; + vmExecutor?: typeof runInQuickJs; maxConcurrency?: number; maxAgents?: number; agentTimeoutMs?: number; @@ -131,18 +131,7 @@ async function exists(filePath: string): Promise { /** Register the feature-gated Workflow tool and /workflows status command. */ export function createStepWorkflowExtension(options: StepWorkflowExtensionOptions = {}): ExtensionFactory { return (pi: ExtensionAPI): void => { - const registration = resolveWorkflowRegistration(options); - if (!registration.enabled) { - if (registration.reason === "vm-unavailable") { - let warned = false; - pi.on("session_start", (_event, ctx) => { - if (warned) return; - warned = true; - ctx.ui.notify(WORKFLOW_VM_UNAVAILABLE_WARNING, "warning"); - }); - } - return; - } + if (!resolveWorkflowRegistration(options).enabled) return; const activeRuns = new Set(); const runner = options.runner ?? createDefaultWorkflowAgentRunner(); const homeRoot = options.homeRoot ?? resolveStepStorageRoot(); diff --git a/packages/coding-agent/src/features/workflow/vm-quickjs.ts b/packages/coding-agent/src/features/workflow/vm-quickjs.ts deleted file mode 100644 index 2189b050..00000000 --- a/packages/coding-agent/src/features/workflow/vm-quickjs.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * QuickJS (WebAssembly) workflow executor. - * - * `runInIsolatedVm` links V8's C++ API through the `isolated-vm` native addon, - * so it can only load on a V8 host. The shipped executable is built with - * `bun build --compile`, whose engine is JavaScriptCore, and there the addon can - * never load however it is installed — which silently took the whole workflow - * tool (and with it the ultraloop opt-in, since both share one registration - * gate) out of every released build. This module is the sandbox for that host: - * QuickJS compiled to WebAssembly runs on V8 and JavaScriptCore alike. - * - * It implements the same contract as `runInIsolatedVm` — identical signature, - * identical guest globals, identical limits — so `WorkflowRuntime.vmExecutor` - * can swap one for the other without the rest of the workflow stack noticing. - * The engine-independent pieces (script-size cap, memory/timeout clamps, - * `export` rewriting, `meta` normalization, JSON-safe argument copying) are - * imported from `vm.ts` rather than reimplemented, so the two executors cannot - * drift apart on those. - * - * Two deliberate choices: - * - * - The `singlefile` QuickJS variant is mandatory. The default `wasmfile` - * variant loads `emscripten-module.wasm` from disk next to its module, - * which does not exist inside a compiled executable's virtual filesystem - * (`ENOENT /$bunfs/root/emscripten-module.wasm`). `singlefile` inlines the - * module instead. - * - Everything crossing the boundary is JSON text. QuickJS lives in its own - * WebAssembly memory, so a guest value is never a host object reference; - * encoding explicitly keeps the copy semantics of the `isolated-vm` path - * (`ExternalCopy` / `copy: true`) visible instead of implicit. Values the VM - * contract already excludes — functions, symbols — are not transferable - * either way. - */ - -import type { QuickJSContext, QuickJSHandle, QuickJSRuntime, QuickJSWASMModule } from "quickjs-emscripten-core"; -import { - clampMemory, - clampTimeout, - normalizeMeta, - toJsonSafe, - transformExports, - WORKFLOW_MAX_SCRIPT_BYTES, - type WorkflowVmHost, - type WorkflowVmOptions, - type WorkflowVmResult, -} from "./vm.ts"; - -/** - * The WebAssembly module is process-wide and costs ~30ms to instantiate, so it - * is built once on first use. Loading is dynamic to keep the ~3MB inlined module - * out of a session that never runs a workflow, and off the V8 path entirely. - */ -let modulePromise: Promise | undefined; - -async function loadQuickJsModule(): Promise { - modulePromise ??= (async () => { - const [core, variant] = await Promise.all([ - import("quickjs-emscripten-core"), - import("@jitl/quickjs-singlefile-mjs-release-sync"), - ]); - return core.newQuickJSWASMModuleFromVariant(variant.default); - })(); - return modulePromise; -} - -/** Whether this runtime can execute workflows through QuickJS. Always true; the module ships with the package. */ -export function isQuickJsVmAvailable(): boolean { - return true; -} - -/** Mirrors the pending-call bookkeeping in `vm.ts` so a slow agent cannot trip the script timeout. */ -interface HostCallTracker { - pending: number; - onSettled?: () => void; -} - -/** - * Run a workflow script inside a QuickJS WebAssembly context. - * - * Signature-compatible with `runInIsolatedVm`; see that function for the shared - * contract. - */ -export async function runInQuickJs( - script: string, - args: unknown, - host: WorkflowVmHost, - options: WorkflowVmOptions = {}, -): Promise { - const sourceBytes = Buffer.byteLength(script, "utf8"); - if (sourceBytes > WORKFLOW_MAX_SCRIPT_BYTES) { - throw new Error(`Workflow script exceeds ${WORKFLOW_MAX_SCRIPT_BYTES} bytes`); - } - const quickjs = await loadQuickJsModule(); - const timeoutMs = clampTimeout(options.timeoutMs); - const runtime = quickjs.newRuntime(); - runtime.setMemoryLimit(clampMemory(options.memoryLimitMb) * 1024 * 1024); - - const hostCalls: HostCallTracker = { pending: 0 }; - // Refreshed by armTimeout(); the interrupt handler reads it to stop a - // CPU-bound guest loop, which no host-side timer can preempt. - let interruptDeadline = Date.now() + timeoutMs; - let timedOut = false; - runtime.setInterruptHandler(() => { - if (hostCalls.pending > 0) return false; - if (Date.now() <= interruptDeadline) return false; - timedOut = true; - return true; - }); - - const context = runtime.newContext(); - let timeout: ReturnType | undefined; - let rejectTimeout: ((reason?: unknown) => void) | undefined; - const timeoutPromise = new Promise((_resolve, reject) => { - rejectTimeout = reject; - }); - const timeoutError = (): Error => new Error(`Workflow script timed out after ${timeoutMs}ms`); - const checkTimeout = (): void => { - if (hostCalls.pending > 0) { - timeout = setTimeout(checkTimeout, Math.min(100, timeoutMs)); - return; - } - timedOut = true; - rejectTimeout?.(timeoutError()); - }; - const armTimeout = (): void => { - if (timeout) clearTimeout(timeout); - interruptDeadline = Date.now() + timeoutMs; - timeout = setTimeout(checkTimeout, timeoutMs); - }; - hostCalls.onSettled = (): void => { - if (hostCalls.pending === 0) armTimeout(); - }; - - // Every handle created here must be released before `context.dispose()`, or - // QuickJS aborts the whole WebAssembly instance on `JS_FreeRuntime` - // ("Assertion failed: list_empty(&rt->gc_obj_list)") and poisons the cached - // module for every later run. Disposal order matters too: context first, - // runtime second. - let evaluated: QuickJSHandle | undefined; - let abandoned: QuickJSHandle | undefined; - try { - installHostBridge(context, runtime, host, hostCalls); - setStringProp(context, "__workflow_args_json", argsJson(args)); - armTimeout(); - const result = context.evalCode(buildScript(script, options.replay === true), options.filename ?? "workflow.js"); - if (result.error) { - throw toHostError(context, result.error, timedOut ? timeoutError() : undefined); - } - evaluated = result.value; - const pending = context.resolvePromise(evaluated); - // When the watchdog wins the race below, this settles afterwards; keep the - // handle so the finally can release it instead of leaking it. - void pending.then( - (settled) => { - abandoned = settled.error ?? settled.value; - }, - () => {}, - ); - // An interrupt inside the guest's async body, or a synchronous throw, becomes - // a rejected promise that nothing else will advance — pump once after - // attaching, or an immediate failure never surfaces. - runtime.executePendingJobs(); - const settled = await Promise.race([pending, timeoutPromise]); - abandoned = undefined; - if (settled.error) { - throw toHostError(context, settled.error, timedOut ? timeoutError() : undefined); - } - const json = context.getString(settled.value); - settled.value.dispose(); - return readResult(json); - } finally { - if (timeout) clearTimeout(timeout); - hostCalls.onSettled = undefined; - // A rejected timeoutPromise with no other listener would surface as an - // unhandled rejection once this frame unwinds. - timeoutPromise.catch(() => {}); - runtime.removeInterruptHandler(); - abandoned?.dispose(); - evaluated?.dispose(); - context.dispose(); - runtime.dispose(); - } -} - -/** `setProp` copies the value into the context, so the temporary handle is released right after. */ -function setStringProp(context: QuickJSContext, key: string, value: string): void { - const handle = context.newString(value); - context.setProp(context.global, key, handle); - handle.dispose(); -} - -function argsJson(args: unknown): string { - // toJsonSafe keeps this byte-identical to what the isolated-vm path copies in, - // including its treatment of values JSON has no representation for. - return JSON.stringify(toJsonSafe(args)) ?? "null"; -} - -function readResult(json: string): WorkflowVmResult { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return { value: null, meta: {} }; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { value: parsed ?? null, meta: {} }; - } - const record = parsed as { value?: unknown; meta?: unknown }; - return { value: record.value ?? null, meta: normalizeMeta(record.meta) }; -} - -/** - * Convert a guest exception into a host Error. `override` replaces the message - * when the failure was our own interrupt, so a timeout reads the same as it does - * on the isolated-vm path instead of surfacing QuickJS's "interrupted". - */ -function toHostError(context: QuickJSContext, handle: QuickJSHandle, override?: Error): Error { - const dumped: unknown = context.dump(handle); - handle.dispose(); - if (override) return override; - if (dumped && typeof dumped === "object") { - const record = dumped as { name?: unknown; message?: unknown; stack?: unknown }; - const message = typeof record.message === "string" ? record.message : JSON.stringify(dumped); - const error = new Error(message); - if (typeof record.name === "string") error.name = record.name; - if (typeof record.stack === "string") error.stack = `${record.name ?? "Error"}: ${message}\n${record.stack}`; - return error; - } - return new Error(typeof dumped === "string" ? dumped : String(dumped)); -} - -/** - * Install the host callbacks the guest prelude wires up. Async calls hand the - * guest a deferred promise and pump the job queue once the host settles it — - * this is what lets `await agent()` work without an Asyncify build. - */ -function installHostBridge( - context: QuickJSContext, - runtime: QuickJSRuntime, - host: WorkflowVmHost, - hostCalls: HostCallTracker, -): void { - const asyncBridge = (name: string, operation: (args: unknown[]) => Promise): void => { - const fn = context.newFunction(name, (...handles) => { - const values = handles.map((handle) => context.dump(handle)); - const deferred = context.newPromise(); - hostCalls.pending += 1; - void Promise.resolve() - .then(() => operation(values)) - .then( - (result) => { - const encoded = context.newString(JSON.stringify(toJsonSafe(result)) ?? "null"); - deferred.resolve(encoded); - encoded.dispose(); - }, - (error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const encoded = context.newError(message); - deferred.reject(encoded); - encoded.dispose(); - }, - ) - .finally(() => { - hostCalls.pending = Math.max(0, hostCalls.pending - 1); - hostCalls.onSettled?.(); - // The guest is parked on this promise; nothing advances it until - // the job queue runs. - runtime.executePendingJobs(); - }); - return deferred.handle; - }); - context.setProp(context.global, name, fn); - fn.dispose(); - }; - - asyncBridge("__workflow_agent", async (values) => { - const prompt = typeof values[0] === "string" ? values[0] : String(values[0] ?? ""); - const parsed = parseJsonRecord(values[1]); - return host.agent(prompt, parsed); - }); - asyncBridge("__workflow_iterate", async (values) => host.iterate(parseJsonRecord(values[0]))); - asyncBridge("__workflow_nested", async (values) => { - const name = typeof values[0] === "string" ? values[0] : ""; - const parsed = values[1] === undefined ? null : parseJsonValue(values[1]); - return host.nestedWorkflow(name, parsed); - }); - - // Every sync callback returns a handle; the ones with nothing to report hand - // back `context.undefined`, a static-lifetime handle that must not be disposed. - const syncBridge = (name: string, operation: (values: unknown[]) => QuickJSHandle): void => { - const fn = context.newFunction(name, (...handles) => operation(handles.map((handle) => context.dump(handle)))); - context.setProp(context.global, name, fn); - fn.dispose(); - }; - - syncBridge("__workflow_phase", (values) => { - host.phase(String(values[0] ?? "")); - return context.undefined; - }); - syncBridge("__workflow_log", (values) => { - host.log(String(values[0] ?? "")); - return context.undefined; - }); - syncBridge("__workflow_spent", () => context.newNumber(host.budgetSpent())); - syncBridge("__workflow_remaining", () => context.newNumber(host.budgetRemaining())); - - const total = host.budgetTotal(); - const totalHandle = total === null ? context.null : context.newNumber(total); - context.setProp(context.global, "__workflow_total", totalHandle); - if (total !== null) totalHandle.dispose(); -} - -function parseJsonRecord(value: unknown): Record { - const parsed = parseJsonValue(value); - return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; -} - -function parseJsonValue(value: unknown): unknown { - if (typeof value !== "string") return null; - try { - return JSON.parse(value) as unknown; - } catch { - return null; - } -} - -/** - * Guest prelude. Mirrors `buildScript` in `vm.ts`: same globals, same barrier - * semantics for `parallel`/`pipeline`, same 4096-entry caps, same blocked - * clock/randomness/host surface. The only difference is the bridge — plain - * function calls exchanging JSON text instead of `isolated-vm` references. - */ -function buildScript(script: string, replay: boolean): string { - const userSource = JSON.stringify(transformExports(script)); - const clockMessage = replay - ? "Non-deterministic clock access is disabled during workflow replay" - : "Workflow scripts cannot access wall-clock or random values"; - return ` -(() => { -const __workflow_forbidden_now = () => { throw new Error(${JSON.stringify(clockMessage)}); }; -const __workflow_forbidden_date = function() { throw new Error("Workflow scripts cannot construct Date values"); }; -Object.defineProperty(__workflow_forbidden_date, "now", { value: __workflow_forbidden_now, writable: false, configurable: false }); -Object.defineProperty(globalThis, "Date", { value: __workflow_forbidden_date, writable: false, configurable: false }); -Object.defineProperty(Math, "random", { value: __workflow_forbidden_now, writable: false, configurable: false }); -const __workflow_forbidden_intl_date = function() { throw new Error("Workflow scripts cannot access wall-clock through Intl"); }; -if (typeof Intl === "object" && Intl !== null) { - Object.defineProperty(Intl, "DateTimeFormat", { value: __workflow_forbidden_intl_date, writable: false, configurable: false }); -} -globalThis.__workflow_meta = null; -const __workflow_agent_ref = __workflow_agent; -const __workflow_iterate_ref = __workflow_iterate; -const __workflow_nested_ref = __workflow_nested; -const __workflow_phase_ref = __workflow_phase; -const __workflow_log_ref = __workflow_log; -const __workflow_spent_ref = __workflow_spent; -const __workflow_remaining_ref = __workflow_remaining; -const __workflow_args_raw = __workflow_args_json; -for (const name of [ - "__workflow_agent", - "__workflow_iterate", - "__workflow_nested", - "__workflow_phase", - "__workflow_log", - "__workflow_spent", - "__workflow_remaining", - "__workflow_args_json", -]) { - Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false }); -} -const __workflow_encode = (value) => JSON.stringify(value === undefined ? null : value); -const __workflow_decode = (json) => (typeof json === "string" ? JSON.parse(json) : null); -globalThis.args = __workflow_decode(__workflow_args_raw); -globalThis.agent = async (prompt, options = {}) => __workflow_decode(await __workflow_agent_ref(String(prompt === undefined ? "" : prompt), __workflow_encode(options))); -globalThis.iterate = async (options) => __workflow_decode(await __workflow_iterate_ref(__workflow_encode(options || {}))); -globalThis.workflow = async (name, options = null) => __workflow_decode(await __workflow_nested_ref(String(name === undefined ? "" : name), __workflow_encode(options))); -globalThis.parallel = async (tasks) => { - if (!Array.isArray(tasks)) throw new TypeError("parallel() requires an array of task functions"); - if (tasks.length > 4096) throw new RangeError("parallel() accepts at most 4096 tasks"); - return Promise.all(tasks.map(async (task) => { - if (typeof task !== "function") throw new TypeError("parallel() entries must be functions"); - try { return await task(); } catch { return null; } - })); -}; -globalThis.pipeline = async (items, ...stages) => { - if (!Array.isArray(items)) throw new TypeError("pipeline() requires an array of items"); - if (items.length > 4096) throw new RangeError("pipeline() accepts at most 4096 items"); - if (stages.some((stage) => typeof stage !== "function")) { - throw new TypeError("pipeline() stages must be functions"); - } - return Promise.all(items.map(async (item, index) => { - let value = item; - for (const stage of stages) { - try { value = await stage(value, item, index); } catch { return null; } - } - return value; - })); -}; -globalThis.phase = (title) => { __workflow_phase_ref(String(title === undefined ? "" : title)); }; -globalThis.log = (message) => { __workflow_log_ref(String(message === undefined ? "" : message)); }; -globalThis.budget = Object.freeze({ - total: __workflow_total, - spent: () => __workflow_spent_ref(), - remaining: () => __workflow_remaining_ref(), -}); -Object.defineProperty(globalThis, "process", { value: undefined, writable: false, configurable: false }); -Object.defineProperty(globalThis, "require", { value: undefined, writable: false, configurable: false }); -Object.defineProperty(globalThis, "fetch", { value: undefined, writable: false, configurable: false }); -const __workflow_main = Object.getPrototypeOf(async function() {}).constructor(${userSource}); -return __workflow_main().then((__workflow_value) => JSON.stringify({ - value: __workflow_value === undefined ? null : __workflow_value, - meta: globalThis.__workflow_meta || {}, -})); -})() -`; -} diff --git a/packages/coding-agent/src/features/workflow/vm.ts b/packages/coding-agent/src/features/workflow/vm.ts index 667075e2..a8ed51d2 100644 --- a/packages/coding-agent/src/features/workflow/vm.ts +++ b/packages/coding-agent/src/features/workflow/vm.ts @@ -1,43 +1,56 @@ -import { createRequire } from "node:module"; +/** + * The workflow sandbox: QuickJS compiled to WebAssembly. + * + * A workflow script is authored by the model, so it runs isolated — no host + * object references, no `process`/`require`/`fetch`, no wall clock and no + * randomness (the last two keep journal replay deterministic), under a memory + * cap and a timeout. + * + * QuickJS-on-WebAssembly is engine-agnostic, which is the point. The previous + * sandbox, `isolated-vm`, is a native addon that links V8's C++ API directly, so + * it could only load on a V8 host: the released executable is built with + * `bun build --compile` and runs on JavaScriptCore, where that addon can never + * load however it is installed. Workflows — and with them the ultraloop opt-in, + * which shares the workflow registration gate — were therefore silently absent + * from every released build while working fine in a source run on Node. One + * engine for both runtimes removes that class of divergence, and costs nothing + * that matters here: workflow scripts are orchestration code that spends its + * time awaiting agents, not computing. + * + * Two constraints this file depends on, both learned the hard way: + * + * - The `singlefile` QuickJS variant is mandatory. The default `wasmfile` + * variant loads `emscripten-module.wasm` from disk beside its own module, + * and that path does not exist inside a compiled executable's virtual + * filesystem (`ENOENT /$bunfs/root/emscripten-module.wasm`). `singlefile` + * inlines the module instead. + * - Every handle must be released before the context is disposed, and the + * context must be disposed before the runtime. Otherwise QuickJS aborts the + * entire WebAssembly instance on `JS_FreeRuntime` ("Assertion failed: + * list_empty(&rt->gc_obj_list)") — and since the module is cached + * process-wide, that poisons every later run in the session. + * + * Everything crossing the boundary is JSON text. QuickJS lives in its own + * WebAssembly memory, so a guest value can never be a host object reference; + * encoding explicitly keeps the copy semantics visible rather than implicit. + * Values JSON cannot represent — functions, symbols — are not transferable, as + * the VM contract already required. + */ + +import type { + QuickJSContext, + QuickJSDeferredPromise, + QuickJSHandle, + QuickJSRuntime, + QuickJSWASMModule, +} from "quickjs-emscripten-core"; import type { WorkflowJsonSchema, WorkflowMeta } from "./types.ts"; -const require = createRequire(import.meta.url); const MAX_SCRIPT_BYTES = 128 * 1024; const DEFAULT_MEMORY_LIMIT_MB = 64; const DEFAULT_TIMEOUT_MS = 120_000; -interface IsolatedContext { - global: { - set(name: string, value: unknown): Promise; - }; -} - -interface IsolatedScript { - run(context: IsolatedContext, options: { promise: true; copy: true; timeout: number }): Promise; -} - -interface IsolatedReference { - applySync(receiver: unknown, args?: unknown[], options?: unknown): unknown; - apply(receiver: unknown, args?: unknown[], options?: unknown): Promise; -} - -interface IsolatedModule { - Isolate: new (options?: { - memoryLimit?: number; - }) => { - createContext(): Promise; - compileScript(code: string, options?: { filename?: string }): Promise; - dispose(): void; - }; - ExternalCopy: new (value: unknown) => { copyInto(): unknown }; - Reference: new (value: (...args: unknown[]) => unknown) => IsolatedReference; -} - -interface HostCallTracker { - pending: number; - onSettled?: () => void; -} - +/** Host primitives the guest prelude exposes as `agent`, `phase`, `log`, `iterate`, `workflow` and `budget`. */ export interface WorkflowVmHost { agent(prompt: string, options: Record): Promise; phase(title: string): void; @@ -61,47 +74,42 @@ export interface WorkflowVmResult { meta: WorkflowMeta; } -/** Resolve the native module without making it a startup requirement. */ -export function loadIsolatedVm(): IsolatedModule | undefined { - try { - const loaded: unknown = require("isolated-vm"); - if (!loaded || typeof loaded !== "object") return undefined; - const candidate = loaded as Partial; - if ( - typeof candidate.Isolate !== "function" || - typeof candidate.ExternalCopy !== "function" || - typeof candidate.Reference !== "function" - ) { - return undefined; - } - return candidate as IsolatedModule; - } catch { - return undefined; - } -} +/** + * The WebAssembly module is process-wide and costs ~30ms to instantiate, so it + * is built once on first use. Loading is dynamic so a session that never runs a + * workflow never pays for the inlined module. + */ +let modulePromise: Promise | undefined; -export function isIsolatedVmAvailable(): boolean { - return loadIsolatedVm() !== undefined; +async function loadQuickJsModule(): Promise { + modulePromise ??= (async () => { + const [core, variant] = await Promise.all([ + import("quickjs-emscripten-core"), + import("@jitl/quickjs-singlefile-mjs-release-sync"), + ]); + return core.newQuickJSWASMModuleFromVariant(variant.default); + })(); + return modulePromise; } -/** - * Whether this runtime can host isolated-vm. isolated-vm is a native addon that - * links V8's C++ API directly, so the bun single-binary we ship — engine is - * JavaScriptCore, not V8 — can never load it however it is installed, while - * Node can. We key off bun explicitly rather than probing `process.versions.v8` - * because bun fills in a node-compat `process.versions.v8` (and `.node`) too, so - * a v8-key test would wrongly report bun as hostable. This is therefore a bun - * check, not a general non-V8 detector: any other (hypothetical) non-V8 runtime - * is treated as hostable and would still get the actionable warning. Callers use - * it to separate a fixable install gap (Node: warn, "reinstall isolated-vm" - * works) from an unfixable runtime fact (the shipped binary: stay silent). - */ -export function isIsolatedVmHostable(): boolean { - return !("bun" in process.versions); +/** Bookkeeping shared between the watchdogs and the host bridge. */ +interface HostCallTracker { + pending: number; + onSettled?: () => void; + /** Pushes the interrupt deadline out; called whenever a host call settles. */ + refreshDeadline?: () => void; + /** + * Deferred promises handed to the guest that have not settled yet. A script can + * return without awaiting them ("fire and forget"), so the run has to release + * them itself; leaving them alive aborts the whole WebAssembly instance. + */ + live: Set; + /** Set just before the context is disposed. A late settlement must not touch the VM after this. */ + closed: boolean; } -/** Run a workflow script inside an isolated-vm context. */ -export async function runInIsolatedVm( +/** Run a workflow script inside a QuickJS WebAssembly context. */ +export async function runInQuickJs( script: string, args: unknown, host: WorkflowVmHost, @@ -109,119 +117,285 @@ export async function runInIsolatedVm( ): Promise { const sourceBytes = Buffer.byteLength(script, "utf8"); if (sourceBytes > MAX_SCRIPT_BYTES) throw new Error(`Workflow script exceeds ${MAX_SCRIPT_BYTES} bytes`); - const ivm = loadIsolatedVm(); - if (!ivm) throw new Error("Workflow runtime unavailable: isolated-vm is not installed or failed to load"); - const isolate = new ivm.Isolate({ memoryLimit: clampMemory(options.memoryLimitMb) }); + const quickjs = await loadQuickJsModule(); + const timeoutMs = clampTimeout(options.timeoutMs); + const runtime = quickjs.newRuntime(); + runtime.setMemoryLimit(clampMemory(options.memoryLimitMb) * 1024 * 1024); + + const hostCalls: HostCallTracker = { pending: 0, live: new Set(), closed: false }; + /** + * Deadline for the in-VM interrupt handler, measured from the last sign of host + * activity rather than from the start of the run. + * + * It must not exempt pending host calls the way the host-side timer below does. + * The handler only runs while the guest is executing bytecode, so a guest parked + * on `await` never reaches it — which means the only situation an exemption + * could ever apply to is a guest burning CPU while a host call is in flight, and + * that is exactly the case that has to be stopped. Nothing else can stop it + * either: the loop runs inside the WebAssembly call and blocks the host event + * loop, so the timer cannot even fire. + * + * Refreshing on every settled call (not only when the last one settles) is what + * keeps a healthy fan-out alive: a wave whose first agent takes longer than + * `timeoutMs` would otherwise resume against a stale deadline and be interrupted + * mid-continuation — and `parallel`/`pipeline` would swallow that into a `null` + * entry, turning a spurious timeout into a silently wrong result. + */ + let interruptDeadline = Date.now() + timeoutMs; + let timedOut = false; + const refreshDeadline = (): void => { + interruptDeadline = Date.now() + timeoutMs; + }; + hostCalls.refreshDeadline = refreshDeadline; + runtime.setInterruptHandler(() => { + if (Date.now() <= interruptDeadline) return false; + timedOut = true; + return true; + }); + + const context = runtime.newContext(); + let timeout: ReturnType | undefined; + let rejectTimeout: ((reason?: unknown) => void) | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + rejectTimeout = reject; + }); + const timeoutError = (): Error => new Error(`Workflow script timed out after ${timeoutMs}ms`); + const checkTimeout = (): void => { + if (hostCalls.pending > 0) { + timeout = setTimeout(checkTimeout, Math.min(100, timeoutMs)); + return; + } + timedOut = true; + rejectTimeout?.(timeoutError()); + }; + const armTimeout = (): void => { + if (timeout) clearTimeout(timeout); + refreshDeadline(); + timeout = setTimeout(checkTimeout, timeoutMs); + }; + hostCalls.onSettled = (): void => { + if (hostCalls.pending === 0) armTimeout(); + }; + + // See the module header: release every handle before disposing the context, and + // dispose the context before the runtime, or QuickJS aborts the whole instance. + let evaluated: QuickJSHandle | undefined; + let abandoned: QuickJSHandle | undefined; try { - const context = await isolate.createContext(); - await context.global.set("args", new ivm.ExternalCopy(toJsonSafe(args)).copyInto()); - const hostCalls: HostCallTracker = { pending: 0 }; - await installHostBridge(ivm, context, host, hostCalls); - const wrapped = buildScript(script, options.replay === true); - const compiled = await isolate.compileScript(wrapped, { filename: options.filename ?? "workflow.js" }); - const timeoutMs = clampTimeout(options.timeoutMs); - let timeout: ReturnType | undefined; - let rejectTimeout: ((reason?: unknown) => void) | undefined; - const timeoutPromise = new Promise((_resolve, reject) => { - rejectTimeout = reject; - }); - const checkTimeout = (): void => { - if (hostCalls.pending > 0) { - timeout = setTimeout(checkTimeout, Math.min(100, timeoutMs)); - return; - } - rejectTimeout?.(new Error(`Workflow script timed out after ${timeoutMs}ms`)); - }; - const armTimeout = (): void => { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(checkTimeout, timeoutMs); - }; - hostCalls.onSettled = (): void => { - if (hostCalls.pending === 0) armTimeout(); - }; + installHostBridge(context, runtime, host, hostCalls); + setStringProp(context, "__workflow_args_json", argsJson(args)); armTimeout(); - try { - const result = await Promise.race([ - compiled.run(context, { - promise: true, - copy: true, - timeout: timeoutMs, - }), - timeoutPromise, - ]); - if (!result || typeof result !== "object" || Array.isArray(result)) { - return { value: result ?? null, meta: {} }; - } - const record = result as { value?: unknown; meta?: unknown }; - return { - value: record.value ?? null, - meta: normalizeMeta(record.meta), - }; - } finally { - if (timeout) clearTimeout(timeout); - hostCalls.onSettled = undefined; + const result = context.evalCode(buildScript(script, options.replay === true), options.filename ?? "workflow.js"); + if (result.error) { + throw toHostError(context, result.error, timedOut ? timeoutError() : undefined); + } + evaluated = result.value; + const pending = context.resolvePromise(evaluated); + // When the watchdog wins the race below, this settles afterwards; keep the + // handle so the finally can release it instead of leaking it. + void pending.then( + (settled) => { + abandoned = settled.error ?? settled.value; + }, + () => {}, + ); + // An interrupt inside the guest's async body, or a synchronous throw, becomes + // a rejected promise that nothing else will advance — pump once after + // attaching, or an immediate failure never surfaces. + runtime.executePendingJobs(); + const settled = await Promise.race([pending, timeoutPromise]); + abandoned = undefined; + if (settled.error) { + throw toHostError(context, settled.error, timedOut ? timeoutError() : undefined); } + const json = context.getString(settled.value); + settled.value.dispose(); + return readResult(json); } finally { - isolate.dispose(); + if (timeout) clearTimeout(timeout); + hostCalls.onSettled = undefined; + hostCalls.refreshDeadline = undefined; + // A rejected timeoutPromise with no other listener would surface as an + // unhandled rejection once this frame unwinds. + timeoutPromise.catch(() => {}); + runtime.removeInterruptHandler(); + // Close the bridge before tearing anything down, so a host call that settles + // from here on leaves the VM alone, then release the promises a fire-and-forget + // script left behind. + hostCalls.closed = true; + for (const deferred of hostCalls.live) deferred.dispose(); + hostCalls.live.clear(); + abandoned?.dispose(); + evaluated?.dispose(); + context.dispose(); + runtime.dispose(); + } +} + +/** `setProp` copies the value into the context, so the temporary handle is released right after. */ +function setStringProp(context: QuickJSContext, key: string, value: string): void { + const handle = context.newString(value); + context.setProp(context.global, key, handle); + handle.dispose(); +} + +function argsJson(args: unknown): string { + return JSON.stringify(toJsonSafe(args)) ?? "null"; +} + +function readResult(json: string): WorkflowVmResult { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { value: null, meta: {} }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { value: parsed ?? null, meta: {} }; + } + const record = parsed as { value?: unknown; meta?: unknown }; + return { value: record.value ?? null, meta: normalizeMeta(record.meta) }; +} + +/** + * Convert a guest exception into a host Error. `override` replaces the message + * when the failure was our own interrupt, so a timeout reports the timeout + * instead of QuickJS's bare "interrupted". + */ +function toHostError(context: QuickJSContext, handle: QuickJSHandle, override?: Error): Error { + const dumped: unknown = context.dump(handle); + handle.dispose(); + if (override) return override; + if (dumped && typeof dumped === "object") { + const record = dumped as { name?: unknown; message?: unknown; stack?: unknown }; + const message = typeof record.message === "string" ? record.message : JSON.stringify(dumped); + const error = new Error(message); + if (typeof record.name === "string") error.name = record.name; + if (typeof record.stack === "string") error.stack = `${record.name ?? "Error"}: ${message}\n${record.stack}`; + return error; } + return new Error(typeof dumped === "string" ? dumped : String(dumped)); } -async function installHostBridge( - ivm: IsolatedModule, - context: IsolatedContext, +/** + * Install the host callbacks the guest prelude wires up. Async calls hand the + * guest a deferred promise and pump the job queue once the host settles it — + * this is what lets `await agent()` work without an Asyncify build. + */ +function installHostBridge( + context: QuickJSContext, + runtime: QuickJSRuntime, host: WorkflowVmHost, hostCalls: HostCallTracker, -): Promise { - const agentReference = new ivm.Reference(async (...rawArgs: unknown[]) => { - const prompt = typeof rawArgs[0] === "string" ? rawArgs[0] : String(rawArgs[0] ?? ""); - const options = isRecord(rawArgs[1]) ? rawArgs[1] : {}; - return trackHostCall(hostCalls, () => host.agent(prompt, options)); +): void { + const asyncBridge = (name: string, operation: (args: unknown[]) => Promise): void => { + const fn = context.newFunction(name, (...handles) => { + const values = handles.map((handle) => context.dump(handle)); + const deferred = context.newPromise(); + hostCalls.live.add(deferred); + hostCalls.pending += 1; + void Promise.resolve() + .then(() => operation(values)) + .then( + (result) => { + // The run may already have returned without awaiting this call; the + // context is gone and the deferred was released with it. + if (hostCalls.closed) return; + const encoded = context.newString(JSON.stringify(toJsonSafe(result)) ?? "null"); + deferred.resolve(encoded); + encoded.dispose(); + }, + (error: unknown) => { + if (hostCalls.closed) return; + const message = error instanceof Error ? error.message : String(error); + const encoded = context.newError(message); + deferred.reject(encoded); + encoded.dispose(); + }, + ) + .finally(() => { + hostCalls.live.delete(deferred); + hostCalls.pending = Math.max(0, hostCalls.pending - 1); + if (hostCalls.closed) return; + hostCalls.refreshDeadline?.(); + hostCalls.onSettled?.(); + // The guest is parked on this promise; nothing advances it until + // the job queue runs. + runtime.executePendingJobs(); + }); + return deferred.handle; + }); + context.setProp(context.global, name, fn); + fn.dispose(); + }; + + asyncBridge("__workflow_agent", async (values) => { + const prompt = typeof values[0] === "string" ? values[0] : String(values[0] ?? ""); + const parsed = parseJsonRecord(values[1]); + return host.agent(prompt, parsed); }); - const iterateReference = new ivm.Reference(async (...rawArgs: unknown[]) => - trackHostCall(hostCalls, () => host.iterate(isRecord(rawArgs[0]) ? rawArgs[0] : {})), - ); - const workflowReference = new ivm.Reference(async (...rawArgs: unknown[]) => { - const name = typeof rawArgs[0] === "string" ? rawArgs[0] : ""; - return trackHostCall(hostCalls, () => host.nestedWorkflow(name, rawArgs[1] ?? null)); + asyncBridge("__workflow_iterate", async (values) => host.iterate(parseJsonRecord(values[0]))); + asyncBridge("__workflow_nested", async (values) => { + const name = typeof values[0] === "string" ? values[0] : ""; + const parsed = values[1] === undefined ? null : parseJsonValue(values[1]); + return host.nestedWorkflow(name, parsed); }); - const phaseReference = new ivm.Reference((...rawArgs: unknown[]) => { - host.phase(String(rawArgs[0] ?? "")); - return null; + + // Every sync callback returns a handle; the ones with nothing to report hand + // back `context.undefined`, a static-lifetime handle that must not be disposed. + const syncBridge = (name: string, operation: (values: unknown[]) => QuickJSHandle): void => { + const fn = context.newFunction(name, (...handles) => operation(handles.map((handle) => context.dump(handle)))); + context.setProp(context.global, name, fn); + fn.dispose(); + }; + + syncBridge("__workflow_phase", (values) => { + host.phase(String(values[0] ?? "")); + return context.undefined; }); - const logReference = new ivm.Reference((...rawArgs: unknown[]) => { - host.log(String(rawArgs[0] ?? "")); - return null; + syncBridge("__workflow_log", (values) => { + host.log(String(values[0] ?? "")); + return context.undefined; }); - const spentReference = new ivm.Reference(() => host.budgetSpent()); - const remainingReference = new ivm.Reference(() => host.budgetRemaining()); - await context.global.set("__workflow_agent", agentReference); - await context.global.set("__workflow_iterate", iterateReference); - await context.global.set("__workflow_nested", workflowReference); - await context.global.set("__workflow_phase", phaseReference); - await context.global.set("__workflow_log", logReference); - await context.global.set("__workflow_spent", spentReference); - await context.global.set("__workflow_remaining", remainingReference); - await context.global.set("__workflow_total", new ivm.ExternalCopy(host.budgetTotal()).copyInto()); + syncBridge("__workflow_spent", () => context.newNumber(host.budgetSpent())); + syncBridge("__workflow_remaining", () => context.newNumber(host.budgetRemaining())); + + const total = host.budgetTotal(); + const totalHandle = total === null ? context.null : context.newNumber(total); + context.setProp(context.global, "__workflow_total", totalHandle); + if (total !== null) totalHandle.dispose(); } -function trackHostCall(tracker: HostCallTracker, operation: () => Promise): Promise { - tracker.pending += 1; - return Promise.resolve() - .then(operation) - .finally(() => { - tracker.pending = Math.max(0, tracker.pending - 1); - tracker.onSettled?.(); - }); +function parseJsonRecord(value: unknown): Record { + const parsed = parseJsonValue(value); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; } +function parseJsonValue(value: unknown): unknown { + if (typeof value !== "string") return null; + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +/** + * The guest prelude: the globals a workflow script may use, the barrier + * semantics of `parallel`/`pipeline`, the 4096-entry caps, and the blocked + * clock/randomness/host surface. The raw `__workflow_*` bridges are captured + * into locals and then erased from `globalThis` so a script cannot reach them. + */ function buildScript(script: string, replay: boolean): string { const userSource = JSON.stringify(transformExports(script)); - const deterministicGuards = replay - ? `const __workflow_forbidden_now = () => { throw new Error("Non-deterministic clock access is disabled during workflow replay"); };` - : `const __workflow_forbidden_now = () => { throw new Error("Workflow scripts cannot access wall-clock or random values"); };`; + const clockMessage = replay + ? "Non-deterministic clock access is disabled during workflow replay" + : "Workflow scripts cannot access wall-clock or random values"; return ` (() => { -${deterministicGuards} +const __workflow_forbidden_now = () => { throw new Error(${JSON.stringify(clockMessage)}); }; const __workflow_forbidden_date = function() { throw new Error("Workflow scripts cannot construct Date values"); }; Object.defineProperty(__workflow_forbidden_date, "now", { value: __workflow_forbidden_now, writable: false, configurable: false }); Object.defineProperty(globalThis, "Date", { value: __workflow_forbidden_date, writable: false, configurable: false }); @@ -238,6 +412,7 @@ const __workflow_phase_ref = __workflow_phase; const __workflow_log_ref = __workflow_log; const __workflow_spent_ref = __workflow_spent; const __workflow_remaining_ref = __workflow_remaining; +const __workflow_args_raw = __workflow_args_json; for (const name of [ "__workflow_agent", "__workflow_iterate", @@ -246,14 +421,16 @@ for (const name of [ "__workflow_log", "__workflow_spent", "__workflow_remaining", + "__workflow_args_json", ]) { Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false }); } -const __workflow_apply = (ref, values) => ref.apply(undefined, values, { arguments: { copy: true }, result: { promise: true, copy: true } }); -const __workflow_apply_sync = (ref, values) => ref.applySync(undefined, values, { arguments: { copy: true }, result: { copy: true } }); -globalThis.agent = async (prompt, options = {}) => __workflow_apply(__workflow_agent_ref, [prompt, options]); -globalThis.iterate = async (options) => __workflow_apply(__workflow_iterate_ref, [options || {}]); -globalThis.workflow = async (name, options = null) => __workflow_apply(__workflow_nested_ref, [name, options]); +const __workflow_encode = (value) => JSON.stringify(value === undefined ? null : value); +const __workflow_decode = (json) => (typeof json === "string" ? JSON.parse(json) : null); +globalThis.args = __workflow_decode(__workflow_args_raw); +globalThis.agent = async (prompt, options = {}) => __workflow_decode(await __workflow_agent_ref(String(prompt === undefined ? "" : prompt), __workflow_encode(options))); +globalThis.iterate = async (options) => __workflow_decode(await __workflow_iterate_ref(__workflow_encode(options || {}))); +globalThis.workflow = async (name, options = null) => __workflow_decode(await __workflow_nested_ref(String(name === undefined ? "" : name), __workflow_encode(options))); globalThis.parallel = async (tasks) => { if (!Array.isArray(tasks)) throw new TypeError("parallel() requires an array of task functions"); if (tasks.length > 4096) throw new RangeError("parallel() accepts at most 4096 tasks"); @@ -276,18 +453,18 @@ globalThis.pipeline = async (items, ...stages) => { return value; })); }; -globalThis.phase = (title) => { __workflow_apply_sync(__workflow_phase_ref, [title]); }; -globalThis.log = (message) => { __workflow_apply_sync(__workflow_log_ref, [message]); }; +globalThis.phase = (title) => { __workflow_phase_ref(String(title === undefined ? "" : title)); }; +globalThis.log = (message) => { __workflow_log_ref(String(message === undefined ? "" : message)); }; globalThis.budget = Object.freeze({ total: __workflow_total, - spent: () => __workflow_apply_sync(__workflow_spent_ref, []), - remaining: () => __workflow_apply_sync(__workflow_remaining_ref, []), + spent: () => __workflow_spent_ref(), + remaining: () => __workflow_remaining_ref(), }); Object.defineProperty(globalThis, "process", { value: undefined, writable: false, configurable: false }); Object.defineProperty(globalThis, "require", { value: undefined, writable: false, configurable: false }); Object.defineProperty(globalThis, "fetch", { value: undefined, writable: false, configurable: false }); const __workflow_main = Object.getPrototypeOf(async function() {}).constructor(${userSource}); -return __workflow_main().then((__workflow_value) => ({ +return __workflow_main().then((__workflow_value) => JSON.stringify({ value: __workflow_value === undefined ? null : __workflow_value, meta: globalThis.__workflow_meta || {}, })); @@ -295,19 +472,23 @@ return __workflow_main().then((__workflow_value) => ({ `; } -export function transformExports(script: string): string { +/** + * Strip `export` so the script body is valid inside an AsyncFunction, and route + * the `meta` declaration into a global the host can read back after the run. + */ +function transformExports(script: string): string { return script .replace(/^[\t ]*export[\t ]+(?=(?:const|let|var|function|async[\t ]+function|class)\b)/gmu, "") .replace(/^[\t ]*(const|let|var)[\t ]+meta[\t ]*=/mu, "$1 meta = globalThis.__workflow_meta =") .replace(/^\s*export\s*\{[^}]*\};?\s*$/gmu, ""); } -export function clampMemory(value: number | undefined): number { +function clampMemory(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_MEMORY_LIMIT_MB; return Math.max(8, Math.min(256, Math.floor(value))); } -export function clampTimeout(value: number | undefined): number { +function clampTimeout(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_TIMEOUT_MS; return Math.max(100, Math.min(600_000, Math.floor(value))); } @@ -316,7 +497,7 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -export function toJsonSafe(value: unknown): unknown { +function toJsonSafe(value: unknown): unknown { if (value === null || typeof value === "string" || typeof value === "boolean") return value; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (Array.isArray(value)) return value.map((item) => toJsonSafe(item)); @@ -328,7 +509,7 @@ export function toJsonSafe(value: unknown): unknown { return String(value); } -export function normalizeMeta(value: unknown): WorkflowMeta { +function normalizeMeta(value: unknown): WorkflowMeta { if (!isRecord(value)) return {}; const meta: WorkflowMeta = {}; if (typeof value.name === "string") meta.name = value.name.slice(0, 200); diff --git a/packages/coding-agent/test/workflow-extension.test.ts b/packages/coding-agent/test/workflow-extension.test.ts index 68aa78a3..66ee8b72 100644 --- a/packages/coding-agent/test/workflow-extension.test.ts +++ b/packages/coding-agent/test/workflow-extension.test.ts @@ -11,11 +11,9 @@ import { type WorkflowRequest, } from "../src/features/workflow/step-workflow.ts"; import type { WorkflowAgentRunResult, WorkflowProgress, WorkflowRunResult } from "../src/features/workflow/types.ts"; -import { isIsolatedVmAvailable } from "../src/features/workflow/vm.ts"; import type { StepTelemetryReporter } from "../src/step/telemetry.ts"; const cleanups: string[] = []; -const nativeWorkflowTest = test.skipIf(!isIsolatedVmAvailable()); afterEach(async () => { vi.unstubAllEnvs(); @@ -124,7 +122,7 @@ test("child ACL hook blocks writes and emits redacted telemetry", async () => { ]); }); -nativeWorkflowTest("saved nested workflow executes once and a second nesting level is rejected", async () => { +test("saved nested workflow executes once and a second nesting level is rejected", async () => { vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); const cwd = await workspace(); const homeRoot = await workspace("step-workflow-home-"); @@ -150,7 +148,7 @@ nativeWorkflowTest("saved nested workflow executes once and a second nesting lev ); }); -nativeWorkflowTest("saved workflow lookup prefers the project and /workflows reports runs", async () => { +test("saved workflow lookup prefers the project and /workflows reports runs", async () => { vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); const cwd = await workspace(); const homeRoot = await workspace("step-workflow-home-"); diff --git a/packages/coding-agent/test/workflow-registration.test.ts b/packages/coding-agent/test/workflow-registration.test.ts index 29084db2..c0d55a24 100644 --- a/packages/coding-agent/test/workflow-registration.test.ts +++ b/packages/coding-agent/test/workflow-registration.test.ts @@ -1,87 +1,35 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "../src/core/extensions/types.ts"; -import { - resolveWorkflowRegistration, - WORKFLOW_VM_UNAVAILABLE_WARNING, -} from "../src/features/workflow/registration-gate.ts"; +import { resolveWorkflowRegistration } from "../src/features/workflow/registration-gate.ts"; import { createStepWorkflowExtension } from "../src/features/workflow/step-workflow.ts"; -import type * as VmModule from "../src/features/workflow/vm.ts"; - -const runtime = vi.hoisted(() => ({ vmHostable: true })); - -vi.mock("../src/features/workflow/vm.ts", async (importOriginal) => ({ - ...(await importOriginal()), - isIsolatedVmAvailable: () => false, - isIsolatedVmHostable: () => runtime.vmHostable, -})); afterEach(() => { vi.unstubAllEnvs(); - runtime.vmHostable = true; }); describe("resolveWorkflowRegistration", () => { test("registers by default and reports why registration was refused", () => { vi.stubEnv("STEP_ENABLE_WORKFLOW", ""); vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); - // Default-on (Claude Code parity): no option and no env var registers. - expect(resolveWorkflowRegistration({}, true)).toEqual({ enabled: true }); - expect(resolveWorkflowRegistration({ enabled: true }, true)).toEqual({ enabled: true }); + // Default-on (Claude Code parity): no option and no env var registers. The + // sandbox ships with the package, so no runtime can withhold it. + expect(resolveWorkflowRegistration()).toEqual({ enabled: true }); + expect(resolveWorkflowRegistration({})).toEqual({ enabled: true }); + expect(resolveWorkflowRegistration({ enabled: true })).toEqual({ enabled: true }); // Embedder opt-out; STEP_ENABLE_WORKFLOW is no longer read and cannot override it. - expect(resolveWorkflowRegistration({ enabled: false }, true)).toEqual({ - enabled: false, - reason: "not-enabled", - }); + expect(resolveWorkflowRegistration({ enabled: false })).toEqual({ enabled: false, reason: "not-enabled" }); vi.stubEnv("STEP_ENABLE_WORKFLOW", "1"); - expect(resolveWorkflowRegistration({ enabled: false }, true)).toEqual({ - enabled: false, - reason: "not-enabled", - }); + expect(resolveWorkflowRegistration({ enabled: false })).toEqual({ enabled: false, reason: "not-enabled" }); // The env kill switch beats both the default and an explicit enable. vi.stubEnv("STEP_DISABLE_WORKFLOW", "1"); - expect(resolveWorkflowRegistration({}, true)).toEqual({ enabled: false, reason: "disabled-by-env" }); - expect(resolveWorkflowRegistration({ enabled: true }, true)).toEqual({ - enabled: false, - reason: "disabled-by-env", - }); - - // The default-on path still requires a VM (or an injected executor standing in for it). - vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); - expect(resolveWorkflowRegistration({}, false)).toEqual({ enabled: false, reason: "vm-unavailable" }); - // A non-V8 runtime (the shipped bun binary) can never load the V8-native - // isolated-vm, but the bundled QuickJS WebAssembly executor runs there, so - // registration proceeds instead of failing silently. - expect(resolveWorkflowRegistration({}, false, false)).toEqual({ enabled: true }); - expect(resolveWorkflowRegistration({ vmExecutor: () => {} }, false)).toEqual({ enabled: true }); - // An injected executor wins on either runtime. - expect(resolveWorkflowRegistration({ vmExecutor: () => {} }, false, false)).toEqual({ enabled: true }); - }); -}); - -describe("isIsolatedVmHostable", () => { - test("keys off the real runtime, not the file-wide mock", async () => { - // The file mocks vm.ts, so pull the real predicate to exercise its body - - // the one-line detection this whole change hinges on. A plain import here - // would return the mock and test nothing. - const { isIsolatedVmHostable } = await vi.importActual("../src/features/workflow/vm.ts"); - // vitest runs on Node (V8), which never carries a `bun` key. - expect("bun" in process.versions).toBe(false); - expect(isIsolatedVmHostable()).toBe(true); - // Simulate the shipped bun binary. bun also fakes a node-compat - // process.versions.v8, so the predicate must key off `bun`, not `v8`. - const versions = process.versions as Record; - try { - versions.bun = "1.2.18"; - expect(isIsolatedVmHostable()).toBe(false); - } finally { - delete versions.bun; - } + expect(resolveWorkflowRegistration()).toEqual({ enabled: false, reason: "disabled-by-env" }); + expect(resolveWorkflowRegistration({ enabled: true })).toEqual({ enabled: false, reason: "disabled-by-env" }); }); }); -describe("workflow registration warning", () => { +describe("workflow registration", () => { function harness() { const tools = new Map(); const handlers = new Map unknown>>(); @@ -101,25 +49,13 @@ describe("workflow registration warning", () => { return { api, tools, handlers, notifications, ctx }; } - test("a default-registered workflow with no VM warns once at session start", () => { - vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); - const h = harness(); - createStepWorkflowExtension({})(h.api); - expect(h.tools.size).toBe(0); - for (const handler of h.handlers.get("session_start") ?? []) handler({ type: "session_start" }, h.ctx); - for (const handler of h.handlers.get("session_start") ?? []) handler({ type: "session_start" }, h.ctx); - expect(h.notifications).toEqual([{ message: WORKFLOW_VM_UNAVAILABLE_WARNING, level: "warning" }]); - expect(h.notifications[0]?.message).toContain("isolated-vm"); - }); - - test("a non-V8 runtime registers through the QuickJS executor and stays silent", () => { + test("registers the tool on every runtime without warning", () => { vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); - runtime.vmHostable = false; const h = harness(); createStepWorkflowExtension({})(h.api); - // isolated-vm can never load here, but the bundled QuickJS WebAssembly - // executor can, so the tool registers and there is nothing to warn about. - // This is the path every released executable takes. + // This is the path every released executable takes: the sandbox is bundled + // QuickJS, so there is no environment left to degrade into, and nothing to + // warn about at session start. expect(h.tools.has("workflow")).toBe(true); for (const handler of h.handlers.get("session_start") ?? []) handler({ type: "session_start" }, h.ctx); expect(h.notifications).toEqual([]); @@ -131,15 +67,17 @@ describe("workflow registration warning", () => { createStepWorkflowExtension({ enabled: false })(optedOut.api); expect(optedOut.tools.size).toBe(0); expect(optedOut.handlers.size).toBe(0); + expect(optedOut.notifications).toEqual([]); vi.stubEnv("STEP_DISABLE_WORKFLOW", "1"); const envOff = harness(); createStepWorkflowExtension({})(envOff.api); expect(envOff.tools.size).toBe(0); expect(envOff.handlers.size).toBe(0); + expect(envOff.notifications).toEqual([]); }); - test("an injected vmExecutor keeps default registration working without the native module", () => { + test("an injected vmExecutor still overrides the bundled sandbox", () => { vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); const h = harness(); createStepWorkflowExtension({ diff --git a/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts b/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts index bae9c3e1..e58f9ca4 100644 --- a/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts +++ b/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts @@ -8,9 +8,6 @@ import { detectUltraloopOptIn, type UltraloopTurnState, } from "../src/features/workflow/ultraloop-opt-in.ts"; -import { isIsolatedVmAvailable } from "../src/features/workflow/vm.ts"; - -const nativeUltraloopTest = test.skipIf(!isIsolatedVmAvailable()); afterEach(() => { vi.unstubAllEnvs(); @@ -148,7 +145,7 @@ describe("buildUltraloopSessionReminder", () => { }); describe("createUltraloopOptInExtension", () => { - nativeUltraloopTest("subscribes handlers only when enabled and native runtime is present", () => { + test("subscribes handlers only when workflow registration is enabled", () => { const enabled = harness(); createUltraloopOptInExtension({ enabled: true })(enabled.api); expect(enabled.handlers.has("before_agent_start")).toBe(true); diff --git a/packages/coding-agent/test/workflow-vm-conformance.test.ts b/packages/coding-agent/test/workflow-vm-conformance.test.ts deleted file mode 100644 index b4d86ed0..00000000 --- a/packages/coding-agent/test/workflow-vm-conformance.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Cross-executor conformance. - * - * The released executable runs workflows through QuickJS (`runInQuickJs`) while a - * source/Node run uses isolated-vm (`runInIsolatedVm`). Two engines behind one - * contract is the standing risk of this arrangement, so every case here runs - * against BOTH executors and asserts the same observable outcome. A divergence - * shows up as a failure in exactly one column, which is the only reliable way to - * catch drift — the QuickJS path is the one users get, and the isolated-vm path - * is the one developers see. - * - * isolated-vm is an optional native addon, so its column is skipped when the - * module is absent rather than failing the suite. - */ - -import { describe, expect, test } from "vitest"; -import { isIsolatedVmAvailable, runInIsolatedVm, type WorkflowVmHost } from "../src/features/workflow/vm.ts"; -import { runInQuickJs } from "../src/features/workflow/vm-quickjs.ts"; - -type Executor = typeof runInIsolatedVm; - -interface HostCalls { - agents: Array<{ prompt: string; options: Record }>; - phases: string[]; - logs: string[]; - iterates: Record[]; - nested: Array<{ name: string; args: unknown }>; -} - -/** - * Host double shaped like the real one: `agent` echoes what it received so the - * test can assert on marshalling, and resolves asynchronously so the executor's - * promise bridging is actually exercised rather than short-circuited. - */ -function createHost(overrides: Partial = {}): { host: WorkflowVmHost; calls: HostCalls } { - const calls: HostCalls = { agents: [], phases: [], logs: [], iterates: [], nested: [] }; - const host: WorkflowVmHost = { - agent: async (prompt, options) => { - calls.agents.push({ prompt, options }); - await new Promise((resolve) => setTimeout(resolve, 1)); - return { echo: prompt, label: options.label ?? null }; - }, - iterate: async (options) => { - calls.iterates.push(options); - return { iterated: true }; - }, - nestedWorkflow: async (name, args) => { - calls.nested.push({ name, args }); - return { nested: name }; - }, - phase: (title) => calls.phases.push(title), - log: (message) => calls.logs.push(message), - budgetSpent: () => 1_234, - budgetRemaining: () => 8_766, - budgetTotal: () => 10_000, - ...overrides, - }; - return { host, calls }; -} - -const executors: Array<[string, Executor]> = [ - ["quickjs", runInQuickJs], - ...(isIsolatedVmAvailable() ? ([["isolated-vm", runInIsolatedVm]] as Array<[string, Executor]>) : []), -]; - -// Guard against the suite silently degrading to a single column. -test("both executors are under test on a V8 host", () => { - expect(executors.map(([name]) => name)).toContain("quickjs"); - if (isIsolatedVmAvailable()) expect(executors).toHaveLength(2); -}); - -describe.each(executors)("workflow vm conformance (%s)", (_name, run) => { - test("returns the script value and captures meta", async () => { - const { host } = createHost(); - const result = await run( - `export const meta = { name: "demo", description: "d", phases: [{ title: "One" }] }; - return { ok: true, n: 41 + 1 };`, - undefined, - host, - ); - expect(result.value).toEqual({ ok: true, n: 42 }); - expect(result.meta.name).toBe("demo"); - expect(result.meta.phases).toEqual([{ title: "One" }]); - }); - - test("awaits async host calls and passes options through", async () => { - const { host, calls } = createHost(); - const result = await run( - `const a = await agent("first", { label: "L1" }); - const b = await agent("second"); - return [a, b];`, - undefined, - host, - ); - expect(result.value).toEqual([ - { echo: "first", label: "L1" }, - { echo: "second", label: null }, - ]); - expect(calls.agents.map((call) => call.prompt)).toEqual(["first", "second"]); - expect(calls.agents[0]?.options).toEqual({ label: "L1" }); - }); - - test("parallel() is a barrier and swallows task failures as null", async () => { - const { host } = createHost(); - const result = await run( - `const out = await parallel([ - () => agent("a"), - () => { throw new Error("boom"); }, - async () => { await agent("c"); return "kept"; }, - ]); - return out;`, - undefined, - host, - ); - expect(result.value).toEqual([{ echo: "a", label: null }, null, "kept"]); - }); - - test("pipeline() threads stages per item and drops a throwing item to null", async () => { - const { host } = createHost(); - const result = await run( - `const out = await pipeline( - ["x", "boom", "y"], - (item) => { if (item === "boom") throw new Error("stage1"); return item + "1"; }, - (prev, original, index) => prev + ":" + original + ":" + index, - ); - return out;`, - undefined, - host, - ); - expect(result.value).toEqual(["x1:x:0", null, "y1:y:2"]); - }); - - test("rejects oversized parallel()/pipeline() inputs", async () => { - const { host } = createHost(); - await expect(run(`return parallel(new Array(4097).fill(() => 1));`, undefined, host)).rejects.toThrow( - /at most 4096/, - ); - await expect(run(`return pipeline(new Array(4097).fill("x"), (v) => v);`, undefined, host)).rejects.toThrow( - /at most 4096/, - ); - }); - - test("exposes sync host calls and the budget surface", async () => { - const { host, calls } = createHost(); - const result = await run( - `phase("Scan"); - log("hello"); - return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() };`, - undefined, - host, - ); - expect(result.value).toEqual({ total: 10_000, spent: 1_234, remaining: 8_766 }); - expect(calls.phases).toEqual(["Scan"]); - expect(calls.logs).toEqual(["hello"]); - }); - - test("reports an unlimited budget as null", async () => { - const { host } = createHost({ budgetTotal: () => null }); - const result = await run(`return { total: budget.total };`, undefined, host); - expect(result.value).toEqual({ total: null }); - }); - - test("passes args through as a JSON-safe copy", async () => { - const { host } = createHost(); - const result = await run(`return { got: args, type: typeof args };`, { files: ["a.ts"], n: 2 }, host); - expect(result.value).toEqual({ got: { files: ["a.ts"], n: 2 }, type: "object" }); - }); - - test("blocks the clock, randomness, and the host surface", async () => { - const { host } = createHost(); - const result = await run( - `const probe = (fn) => { try { fn(); return "allowed"; } catch (error) { return "blocked"; } }; - return { - dateNow: probe(() => Date.now()), - dateNew: probe(() => new Date()), - random: probe(() => Math.random()), - process: typeof process, - require: typeof require, - fetch: typeof fetch, - };`, - undefined, - host, - ); - expect(result.value).toEqual({ - dateNow: "blocked", - dateNew: "blocked", - random: "blocked", - process: "undefined", - require: "undefined", - fetch: "undefined", - }); - }); - - test("propagates a script throw to the caller", async () => { - const { host } = createHost(); - await expect(run(`throw new Error("script exploded");`, undefined, host)).rejects.toThrow("script exploded"); - }); - - test("propagates a rejected host call the script does not catch", async () => { - const { host } = createHost({ - agent: async () => { - throw new Error("budget exceeded"); - }, - }); - await expect(run(`return agent("x");`, undefined, host)).rejects.toThrow("budget exceeded"); - }); - - test("enforces the script timeout on a runaway loop", async () => { - const { host } = createHost(); - await expect(run(`while (true) {} return 1;`, undefined, host, { timeoutMs: 200 })).rejects.toThrow(/timed out/); - }); - - test("does not count time inside a pending host call against the timeout", async () => { - const { host } = createHost({ - agent: async (prompt) => { - await new Promise((resolve) => setTimeout(resolve, 260)); - return prompt; - }, - }); - // Three 260ms round trips against a 200ms budget: only stretches with no - // pending host call may trip the watchdog. - const result = await run( - `const a = await agent("1"); const b = await agent("2"); const c = await agent("3"); - return [a, b, c].join("|");`, - undefined, - host, - { timeoutMs: 200 }, - ); - expect(result.value).toBe("1|2|3"); - }); - - test("rejects a script over the size cap", async () => { - const { host } = createHost(); - const oversized = `// ${"x".repeat(128 * 1024)}\nreturn 1;`; - await expect(run(oversized, undefined, host, { timeoutMs: 1_000 })).rejects.toThrow(/exceeds/); - }); - - test("enforces the memory limit", async () => { - const { host } = createHost(); - await expect( - run( - `const a = []; for (let i = 0; i < 1e7; i++) a.push({ i, pad: "padpadpad" + i }); return a.length;`, - undefined, - host, - { - memoryLimitMb: 8, - timeoutMs: 10_000, - }, - ), - ).rejects.toThrow(); - }); - - test("routes iterate() and nested workflow() to the host", async () => { - const { host, calls } = createHost(); - const result = await run( - `const it = await iterate({ spec: "s" }); - const nested = await workflow("child", { k: 1 }); - return { it, nested };`, - undefined, - host, - ); - expect(result.value).toEqual({ it: { iterated: true }, nested: { nested: "child" } }); - expect(calls.iterates).toEqual([{ spec: "s" }]); - expect(calls.nested).toEqual([{ name: "child", args: { k: 1 } }]); - }); - - test("uses the replay wording for clock access when replaying", async () => { - const { host } = createHost(); - await expect(run(`return Date.now();`, undefined, host, { replay: true })).rejects.toThrow( - /disabled during workflow replay/, - ); - }); -}); diff --git a/packages/coding-agent/test/workflow-vm.test.ts b/packages/coding-agent/test/workflow-vm.test.ts index 3d0a9d6c..22b1f77a 100644 --- a/packages/coding-agent/test/workflow-vm.test.ts +++ b/packages/coding-agent/test/workflow-vm.test.ts @@ -1,99 +1,134 @@ +/** + * Workflow VM contract. + * + * One executor now serves every runtime — QuickJS compiled to WebAssembly (see + * `vm.ts`) — so this suite is the behavioural spec for the sandbox itself: what + * the guest can reach, what it cannot, and how concurrency, limits, and failures + * are supposed to look. It replaces the split between an isolated-vm suite and a + * cross-executor conformance suite that existed while two engines coexisted. + */ + import { expect, test } from "vitest"; -import { isIsolatedVmAvailable, runInIsolatedVm, type WorkflowVmHost } from "../src/features/workflow/vm.ts"; - -const nativeVmTest = test.skipIf(!isIsolatedVmAvailable()); - -function host(overrides: Partial = {}): WorkflowVmHost { - return { - agent: async (prompt, options) => ({ prompt, options }), - phase: () => {}, - log: () => {}, - iterate: async (options) => options, - nestedWorkflow: async (name, args) => ({ name, args }), - budgetSpent: () => 2, - budgetRemaining: () => 8, - budgetTotal: () => 10, +import { runInQuickJs, type WorkflowVmHost } from "../src/features/workflow/vm.ts"; + +interface HostCalls { + agents: Array<{ prompt: string; options: Record }>; + phases: string[]; + logs: string[]; + iterates: Record[]; + nested: Array<{ name: string; args: unknown }>; +} + +/** + * Host double shaped like the real one. `agent` echoes what it received so a test + * can assert on marshalling, and resolves asynchronously so the executor's + * promise bridging is exercised rather than short-circuited. + */ +function createHost(overrides: Partial = {}): { host: WorkflowVmHost; calls: HostCalls } { + const calls: HostCalls = { agents: [], phases: [], logs: [], iterates: [], nested: [] }; + const host: WorkflowVmHost = { + agent: async (prompt, options) => { + calls.agents.push({ prompt, options }); + await new Promise((resolve) => setTimeout(resolve, 1)); + return { echo: prompt, label: options.label ?? null }; + }, + iterate: async (options) => { + calls.iterates.push(options); + return { iterated: true }; + }, + nestedWorkflow: async (name, args) => { + calls.nested.push({ name, args }); + return { nested: name }; + }, + phase: (title) => calls.phases.push(title), + log: (message) => calls.logs.push(message), + budgetSpent: () => 1_234, + budgetRemaining: () => 8_766, + budgetTotal: () => 10_000, ...overrides, }; + return { host, calls }; +} + +/** Counts how many host calls are in flight at once, for the concurrency assertions. */ +function concurrencyProbe(delayMs = 10): { host: WorkflowVmHost; peak: () => number } { + let active = 0; + let peak = 0; + const { host } = createHost({ + agent: async (prompt) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + active -= 1; + return prompt; + }, + }); + return { host, peak: () => peak }; } -nativeVmTest("workflow VM exposes JSON args, metadata, and host primitives", async () => { - const phases: string[] = []; - const result = await runInIsolatedVm( +test("exposes JSON args, metadata, and every host primitive", async () => { + const { host, calls } = createHost(); + const result = await runInQuickJs( `const meta = { name: "sample", description: "test", roleSchemas: { worker: { type: "string" } } }; - phase("inspect"); - const answer = await agent("hello", { label: "worker" }); - return { args, answer, metaName: meta.name, budget: [budget.total, budget.spent(), budget.remaining()] };`, + phase("inspect"); + log("hello"); + const answer = await agent("hi", { label: "worker" }); + return { args, answer, metaName: meta.name, budget: [budget.total, budget.spent(), budget.remaining()] };`, { input: 42 }, - host({ phase: (title) => phases.push(title) }), + host, ); - expect(result).toEqual({ - value: { - args: { input: 42 }, - answer: { prompt: "hello", options: { label: "worker" } }, - metaName: "sample", - budget: [10, 2, 8], - }, - meta: { name: "sample", description: "test", roleSchemas: { worker: { type: "string" } } }, + expect(result.value).toEqual({ + args: { input: 42 }, + answer: { echo: "hi", label: "worker" }, + metaName: "sample", + budget: [10_000, 1_234, 8_766], }); - expect(phases).toEqual(["inspect"]); + expect(result.meta).toEqual({ + name: "sample", + description: "test", + roleSchemas: { worker: { type: "string" } }, + }); + expect(calls.phases).toEqual(["inspect"]); + expect(calls.logs).toEqual(["hello"]); + expect(calls.agents[0]?.options).toEqual({ label: "worker" }); }); -nativeVmTest("parallel starts every task before its barrier and pipeline preserves stage order", async () => { - let active = 0; - let peak = 0; - const result = await runInIsolatedVm( - `const parallelValues = await parallel([ - () => agent("a"), - () => agent("b"), - () => agent("c"), - ]); - const pipelineValues = await pipeline([1, 2], async (value) => value + 1, async (value) => value * 3); - return { parallelValues, pipelineValues };`, +test("reports an unlimited budget as null", async () => { + const { host } = createHost({ budgetTotal: () => null }); + const result = await runInQuickJs(`return { total: budget.total };`, undefined, host); + expect(result.value).toEqual({ total: null }); +}); + +test("parallel() starts every task before its barrier", async () => { + const probe = concurrencyProbe(); + const result = await runInQuickJs( + `return parallel([() => agent("a"), () => agent("b"), () => agent("c")]);`, null, - host({ - agent: async (prompt) => { - active += 1; - peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, 10)); - active -= 1; - return prompt; - }, - }), + probe.host, ); - - expect(result.value).toEqual({ parallelValues: ["a", "b", "c"], pipelineValues: [6, 9] }); - expect(peak).toBe(3); + expect(result.value).toEqual(["a", "b", "c"]); + expect(probe.peak()).toBe(3); }); -nativeVmTest("pipeline runs item chains concurrently and passes (prev, item, index) to stages", async () => { - let active = 0; - let peak = 0; - const result = await runInIsolatedVm( +test("pipeline() runs item chains concurrently and passes (prev, item, index) to stages", async () => { + const probe = concurrencyProbe(); + const result = await runInQuickJs( `return pipeline( ["a", "b", "c"], async (value, item, index) => (await agent(value)) + ":" + item + ":" + index, async (value, item, index) => value + "/" + index, );`, null, - host({ - agent: async (prompt) => { - active += 1; - peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, 10)); - active -= 1; - return prompt; - }, - }), + probe.host, ); - expect(result.value).toEqual(["a:a:0/0", "b:b:1/1", "c:c:2/2"]); - expect(peak).toBe(3); + expect(probe.peak()).toBe(3); }); -nativeVmTest("a throwing stage or task resolves to null instead of rejecting the wave", async () => { - const result = await runInIsolatedVm( +test("a throwing stage or task resolves to null instead of rejecting the wave", async () => { + const { host } = createHost({ agent: async (prompt) => prompt }); + const result = await runInQuickJs( `const piped = await pipeline( [1, 2, 3], async (value) => { if (value === 2) throw new Error("boom"); return value * 10; }, @@ -106,49 +141,218 @@ nativeVmTest("a throwing stage or task resolves to null instead of rejecting the ]); return { piped, waved };`, null, - host({ agent: async (prompt) => prompt }), + host, ); - expect(result.value).toEqual({ piped: [11, null, 31], waved: ["ok", null, "also-ok"] }); }); -nativeVmTest("pipeline and parallel reject batches above the 4096-entry cap", async () => { - await expect(runInIsolatedVm("return parallel(new Array(4097).fill(() => null));", null, host())).rejects.toThrow( +test("parallel() and pipeline() reject batches above the 4096-entry cap", async () => { + const { host } = createHost(); + await expect(runInQuickJs("return parallel(new Array(4097).fill(() => null));", null, host)).rejects.toThrow( /4096/u, ); await expect( - runInIsolatedVm("return pipeline(new Array(4097).fill(1), async (value) => value);", null, host()), + runInQuickJs("return pipeline(new Array(4097).fill(1), async (value) => value);", null, host), ).rejects.toThrow(/4096/u); }); -nativeVmTest.each([ +test("routes iterate() and nested workflow() to the host", async () => { + const { host, calls } = createHost(); + const result = await runInQuickJs( + `const it = await iterate({ spec: "s" }); + const nested = await workflow("child", { k: 1 }); + return { it, nested };`, + undefined, + host, + ); + expect(result.value).toEqual({ it: { iterated: true }, nested: { nested: "child" } }); + expect(calls.iterates).toEqual([{ spec: "s" }]); + expect(calls.nested).toEqual([{ name: "child", args: { k: 1 } }]); +}); + +test.each([ ["Date.now", "return Date.now();"], ["Date constructor", "return new Date();"], ["Math.random", "return Math.random();"], - ["Intl.DateTimeFormat", "return new Intl.DateTimeFormat().format();"], -])("workflow VM rejects non-deterministic %s access", async (_label, script) => { - await expect(runInIsolatedVm(script, null, host())).rejects.toThrow(/wall-clock|construct Date|random/u); +])("rejects non-deterministic %s access", async (_label, script) => { + const { host } = createHost(); + await expect(runInQuickJs(script, null, host)).rejects.toThrow(/wall-clock|construct Date|random/u); }); -nativeVmTest("workflow VM enforces its synchronous execution timeout", async () => { - await expect(runInIsolatedVm("while (true) {}", null, host(), { timeoutMs: 100 })).rejects.toThrow(/timed out/u); +test("cannot read the clock through Intl", async () => { + const { host } = createHost(); + // QuickJS ships no Intl at all, so the prelude's DateTimeFormat guard never + // even applies here — the reference fails first. Assert the outcome (no clock + // through Intl) rather than the mechanism, so this stays true either way. + await expect(runInQuickJs("return new Intl.DateTimeFormat().format();", null, host)).rejects.toThrow( + /wall-clock|not defined/u, + ); }); -nativeVmTest("workflow VM enforces an async execution timeout", async () => { +test("uses the replay wording for clock access when replaying", async () => { + const { host } = createHost(); + await expect(runInQuickJs(`return Date.now();`, undefined, host, { replay: true })).rejects.toThrow( + /disabled during workflow replay/, + ); +}); + +test("exposes neither the raw host bridges nor the host runtime", async () => { + const { host } = createHost(); + const result = await runInQuickJs( + `return [ + typeof __workflow_agent, + typeof __workflow_iterate, + typeof __workflow_args_json, + typeof process, + typeof require, + typeof fetch, + ];`, + null, + host, + ); + expect(result.value).toEqual(["undefined", "undefined", "undefined", "undefined", "undefined", "undefined"]); +}); + +test("has no dynamic module loader", async () => { + const { host } = createHost(); + await expect(runInQuickJs('return import("node:fs")', null, host)).rejects.toThrow(); +}); + +test("propagates a script throw to the caller", async () => { + const { host } = createHost(); + await expect(runInQuickJs(`throw new Error("script exploded");`, undefined, host)).rejects.toThrow( + "script exploded", + ); +}); + +test("propagates a rejected host call the script does not catch", async () => { + const { host } = createHost({ + agent: async () => { + throw new Error("budget exceeded"); + }, + }); + await expect(runInQuickJs(`return agent("x");`, undefined, host)).rejects.toThrow("budget exceeded"); +}); + +test("enforces the script timeout on a runaway loop", async () => { + const { host } = createHost(); + await expect(runInQuickJs(`while (true) {} return 1;`, undefined, host, { timeoutMs: 200 })).rejects.toThrow( + /timed out/, + ); +}); + +test("enforces the script timeout on a promise that never settles", async () => { + const { host } = createHost(); + await expect(runInQuickJs("return await new Promise(() => {});", null, host, { timeoutMs: 200 })).rejects.toThrow( + /timed out/u, + ); +}); + +test("does not count time inside a pending host call against the timeout", async () => { + const { host } = createHost({ + agent: async (prompt) => { + await new Promise((resolve) => setTimeout(resolve, 260)); + return prompt; + }, + }); + // Three 260ms round trips against a 200ms budget: only stretches with no + // pending host call may trip the watchdog. + const result = await runInQuickJs( + `const a = await agent("1"); const b = await agent("2"); const c = await agent("3"); + return [a, b, c].join("|");`, + undefined, + host, + { timeoutMs: 200 }, + ); + expect(result.value).toBe("1|2|3"); +}); + +test("stops a guest that burns CPU while a host call is in flight", async () => { + const { host } = createHost({ + agent: async (prompt) => { + await new Promise((resolve) => setTimeout(resolve, 3_000)); + return prompt; + }, + }); + // Nothing outside the VM can break this loop: it runs inside the WebAssembly + // call and blocks the host event loop, so the host-side timer never gets to + // fire. Only the in-VM interrupt handler can, which is why that handler must + // not exempt pending host calls. await expect( - runInIsolatedVm("return await new Promise(() => {});", null, host(), { timeoutMs: 100 }), + runInQuickJs(`const p = agent("slow"); let i = 0; while (true) { i = (i + 1) % 1000000; }`, null, host, { + timeoutMs: 300, + }), ).rejects.toThrow(/timed out/u); -}); +}, 10_000); -nativeVmTest("workflow VM does not expose raw host references", async () => { - const result = await runInIsolatedVm( - "return [typeof __workflow_agent, typeof __workflow_iterate, typeof process, typeof require, typeof fetch];", +test("keeps a fan-out branch whose host call outlasts the timeout budget", async () => { + const { host } = createHost({ + agent: async (prompt) => { + await new Promise((resolve) => setTimeout(resolve, prompt === "first" ? 800 : 1_400)); + return prompt; + }, + }); + // The first branch waits 800ms against a 300ms budget, then computes for well + // under it. The interrupt deadline has to be refreshed by that settlement — + // refreshing only once every call has settled would resume this continuation + // against a stale deadline and interrupt it, and parallel() would report the + // branch as a bare `null` rather than an error. + const result = await runInQuickJs( + `return parallel([ + async () => { const v = await agent("first"); let s = 0; for (let i = 0; i < 1000000; i++) s += i % 7; return v + ":" + s; }, + () => agent("second"), + ]);`, null, - host(), + host, + { timeoutMs: 300 }, ); - expect(result.value).toEqual(["undefined", "undefined", "undefined", "undefined", "undefined"]); + expect(result.value).toEqual(["first:2999997", "second"]); +}, 10_000); + +test.each([ + ["a single call", `agent("a"); return "early";`], + ["a parallel wave", `parallel([() => agent("a"), () => agent("b")]); return "early";`], +])("discards %s the script never awaited", async (_label, script) => { + const { host } = createHost({ + agent: async (prompt) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return prompt; + }, + }); + // The run returns while those calls are still in flight. Their deferred + // promises have to be released before the context goes away, or QuickJS aborts + // the shared WebAssembly instance; the settlements that land afterwards must + // leave the disposed context alone rather than raise an unhandled rejection. + const result = await runInQuickJs(script, null, host, { timeoutMs: 5_000 }); + expect(result.value).toBe("early"); + // Give the abandoned calls time to settle: a use-after-free would surface here. + await new Promise((resolve) => setTimeout(resolve, 150)); +}); + +test("keeps working after a run that abandoned host calls", async () => { + const { host } = createHost(); + await runInQuickJs(`agent("a"); return "early";`, null, host, { timeoutMs: 5_000 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + // The WebAssembly module is cached process-wide, so a botched teardown would + // poison every later run rather than just the one that caused it. + const result = await runInQuickJs(`return (await agent("later")).echo;`, null, host); + expect(result.value).toBe("later"); +}); + +test("rejects a script over the size cap", async () => { + const { host } = createHost(); + const oversized = `// ${"x".repeat(128 * 1024)}\nreturn 1;`; + await expect(runInQuickJs(oversized, undefined, host, { timeoutMs: 1_000 })).rejects.toThrow(/exceeds/); }); -nativeVmTest("workflow VM has no dynamic module loader", async () => { - await expect(runInIsolatedVm('return import("node:fs")', null, host())).rejects.toThrow("Not supported"); +test("enforces the memory limit", async () => { + const { host } = createHost(); + await expect( + runInQuickJs( + `const a = []; for (let i = 0; i < 1e7; i++) a.push({ i, pad: "padpadpad" + i }); return a.length;`, + undefined, + host, + { memoryLimitMb: 8, timeoutMs: 10_000 }, + ), + ).rejects.toThrow(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e615607a..58306684 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,9 +197,6 @@ importers: '@mariozechner/clipboard': specifier: 0.3.9 version: 0.3.9 - isolated-vm: - specifier: 6.0.1 - version: 6.0.1 devDependencies: '@types/cross-spawn': specifier: 6.0.6 @@ -1362,10 +1359,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isolated-vm@6.0.1: - resolution: {integrity: sha512-rcnfMOYIbRdChFnQbMYsSx/cSfmLJRiw+MlPyz6WdwhaPDB/mfib0pSK+D2COW+KNZKGOGeW6a+qVksL6+X/Bg==} - engines: {node: '>=22.0.0'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2938,11 +2931,6 @@ snapshots: isexe@2.0.0: {} - isolated-vm@6.0.1: - dependencies: - prebuild-install: 7.1.3 - optional: true - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index fa8d4333..59fdc3eb 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -28,8 +28,6 @@ const allowedExternalPackages = new Set([ // Optional native accelerators. Their callers fall back to JavaScript when absent. "bufferutil", "utf-8-validate", - // Workflow's isolated runtime is a native optional dependency loaded at runtime. - "isolated-vm", // Optional debug output coloring. "supports-color", ]);