From 3d53f3874076e7fdfbfe1f2f562e395c09baff77 Mon Sep 17 00:00:00 2001 From: i-panhaoran Date: Wed, 23 Sep 2026 22:15:53 +0800 Subject: [PATCH] fix: restore workflow and ultracode on the released binary --- packages/coding-agent/package.json | 2 + .../src/features/workflow/index.ts | 8 +- .../features/workflow/registration-gate.ts | 18 +- .../src/features/workflow/runtime.ts | 22 +- .../src/features/workflow/vm-quickjs.ts | 417 ++++++++++++++++++ .../coding-agent/src/features/workflow/vm.ts | 10 +- .../test/workflow-registration.test.ts | 24 +- .../test/workflow-vm-conformance.test.ts | 273 ++++++++++++ pnpm-lock.yaml | 44 ++ 9 files changed, 790 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/src/features/workflow/vm-quickjs.ts create mode 100644 packages/coding-agent/test/workflow-vm-conformance.test.ts diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index eeb92c9d..35e13dab 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -39,6 +39,7 @@ "@step-harness/providers": "^0.84.4", "@step-harness/pi-tui": "^0.84.4", "@step-harness/config": "workspace:*", + "@jitl/quickjs-singlefile-mjs-release-sync": "0.32.0", "@modelcontextprotocol/sdk": "1.27.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -51,6 +52,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "quickjs-emscripten-core": "0.32.0", "semver": "7.8.0", "smol-toml": "1.8.0", "typebox": "1.3.7", diff --git a/packages/coding-agent/src/features/workflow/index.ts b/packages/coding-agent/src/features/workflow/index.ts index e11fe5f1..0db2ece2 100644 --- a/packages/coding-agent/src/features/workflow/index.ts +++ b/packages/coding-agent/src/features/workflow/index.ts @@ -31,7 +31,12 @@ export { workflowHash, } from "./journal.ts"; export { formatWorkflowStatus, listSavedWorkflows, listWorkflowRuns, WorkflowProgressStore } from "./progress.ts"; -export { WorkflowRuntime, WorkflowSchemaError, workflowToolResult } from "./runtime.ts"; +export { + defaultWorkflowVmExecutor, + WorkflowRuntime, + WorkflowSchemaError, + workflowToolResult, +} from "./runtime.ts"; export { validateWorkflowSchema } from "./schema.ts"; export { createStepWorkflowExtension, @@ -49,3 +54,4 @@ export { } 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"; diff --git a/packages/coding-agent/src/features/workflow/registration-gate.ts b/packages/coding-agent/src/features/workflow/registration-gate.ts index f4eaafb4..c82fbcb9 100644 --- a/packages/coding-agent/src/features/workflow/registration-gate.ts +++ b/packages/coding-agent/src/features/workflow/registration-gate.ts @@ -7,12 +7,12 @@ function envFlag(value: string | undefined): boolean { export type WorkflowRegistrationDecision = | { enabled: true } - | { enabled: false; reason: "not-enabled" | "disabled-by-env" | "vm-unavailable" | "vm-unsupported-runtime" }; + | { 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 an unfixable - * runtime fact and stays silent. + * 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."; @@ -31,6 +31,12 @@ export const WORKFLOW_VM_UNAVAILABLE_WARNING = * the ultraloop opt-in. An embedder's `enabled: false` or * STEP_DISABLE_WORKFLOW=1 turns registration off; STEP_ENABLE_WORKFLOW is no * longer read. + * + * 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. */ export function resolveWorkflowRegistration( options: { enabled?: boolean; vmExecutor?: unknown } = {}, @@ -40,9 +46,9 @@ export function resolveWorkflowRegistration( 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) { - // A missing native module is fixable on a V8 runtime (warn so the user can - // reinstall it); on a non-V8 runtime it never loads, so refuse silently. - return { enabled: false, reason: vmHostable ? "vm-unavailable" : "vm-unsupported-runtime" }; + // 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 }; } diff --git a/packages/coding-agent/src/features/workflow/runtime.ts b/packages/coding-agent/src/features/workflow/runtime.ts index e4b90623..83d9ba9a 100644 --- a/packages/coding-agent/src/features/workflow/runtime.ts +++ b/packages/coding-agent/src/features/workflow/runtime.ts @@ -49,7 +49,14 @@ import type { WorkflowUsage, } from "./types.ts"; import { emptyWorkflowUsage, mergeWorkflowUsage, workflowUsageTokens } from "./types.ts"; -import { runInIsolatedVm, type WorkflowVmHost, type WorkflowVmOptions, type WorkflowVmResult } from "./vm.ts"; +import { + isIsolatedVmAvailable, + runInIsolatedVm, + type WorkflowVmHost, + type WorkflowVmOptions, + type WorkflowVmResult, +} from "./vm.ts"; +import { runInQuickJs } from "./vm-quickjs.ts"; const DEFAULT_MAX_ITERATIONS = 20; const MAX_MAX_ITERATIONS = 100; @@ -57,6 +64,17 @@ 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; @@ -141,7 +159,7 @@ export class WorkflowRuntime { this.signal = options.signal; this.nestedWorkflow = options.nestedWorkflow; this.now = options.now ?? Date.now; - this.vmExecutor = options.vmExecutor ?? runInIsolatedVm; + this.vmExecutor = options.vmExecutor ?? defaultWorkflowVmExecutor(); const startedAt = this.readNow(); this.startedAt = startedAt; const initial: WorkflowProgress = { diff --git a/packages/coding-agent/src/features/workflow/vm-quickjs.ts b/packages/coding-agent/src/features/workflow/vm-quickjs.ts new file mode 100644 index 00000000..2189b050 --- /dev/null +++ b/packages/coding-agent/src/features/workflow/vm-quickjs.ts @@ -0,0 +1,417 @@ +/** + * 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 51d390a4..667075e2 100644 --- a/packages/coding-agent/src/features/workflow/vm.ts +++ b/packages/coding-agent/src/features/workflow/vm.ts @@ -295,19 +295,19 @@ return __workflow_main().then((__workflow_value) => ({ `; } -function transformExports(script: string): string { +export 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, ""); } -function clampMemory(value: number | undefined): number { +export 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))); } -function clampTimeout(value: number | undefined): number { +export 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 +316,7 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function toJsonSafe(value: unknown): unknown { +export 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 +328,7 @@ function toJsonSafe(value: unknown): unknown { return String(value); } -function normalizeMeta(value: unknown): WorkflowMeta { +export 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-registration.test.ts b/packages/coding-agent/test/workflow-registration.test.ts index 0d99d627..29084db2 100644 --- a/packages/coding-agent/test/workflow-registration.test.ts +++ b/packages/coding-agent/test/workflow-registration.test.ts @@ -51,16 +51,11 @@ describe("resolveWorkflowRegistration", () => { 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, so the refusal is a distinct, silent reason rather than a - // fixable install gap. - expect(resolveWorkflowRegistration({}, false, false)).toEqual({ - enabled: false, - reason: "vm-unsupported-runtime", - }); + // 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 even on a non-V8 runtime: the executor - // short-circuit is checked before the vmHostable branch, so an embedder can - // register workflows on the shipped bun binary. + // An injected executor wins on either runtime. expect(resolveWorkflowRegistration({ vmExecutor: () => {} }, false, false)).toEqual({ enabled: true }); }); }); @@ -117,15 +112,16 @@ describe("workflow registration warning", () => { expect(h.notifications[0]?.message).toContain("isolated-vm"); }); - test("a non-V8 runtime that can never host the VM registers nothing and stays silent", () => { + test("a non-V8 runtime registers through the QuickJS executor and stays silent", () => { vi.stubEnv("STEP_DISABLE_WORKFLOW", ""); runtime.vmHostable = false; const h = harness(); createStepWorkflowExtension({})(h.api); - // Unlike the vm-unavailable path, vm-unsupported-runtime registers no - // session_start handler at all, so nothing exists that could warn. - expect(h.tools.size).toBe(0); - expect(h.handlers.size).toBe(0); + // 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. + 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([]); }); diff --git a/packages/coding-agent/test/workflow-vm-conformance.test.ts b/packages/coding-agent/test/workflow-vm-conformance.test.ts new file mode 100644 index 00000000..b4d86ed0 --- /dev/null +++ b/packages/coding-agent/test/workflow-vm-conformance.test.ts @@ -0,0 +1,273 @@ +/** + * 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/pnpm-lock.yaml b/pnpm-lock.yaml index f01054ac..e615607a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: packages/coding-agent: dependencies: + '@jitl/quickjs-singlefile-mjs-release-sync': + specifier: 0.32.0 + version: 0.32.0 '@modelcontextprotocol/sdk': specifier: 1.27.1 version: 1.27.1(zod@4.6.1) @@ -169,6 +172,9 @@ importers: proper-lockfile: specifier: 4.1.2 version: 4.1.2 + quickjs-emscripten-core: + specifier: 0.32.0 + version: 0.32.0 semver: specifier: 7.8.0 version: 7.8.0 @@ -386,24 +392,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.5': resolution: {integrity: sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.5': resolution: {integrity: sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.5': resolution: {integrity: sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.5': resolution: {integrity: sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA==} @@ -600,6 +610,12 @@ packages: peerDependencies: hono: ^4 + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-singlefile-mjs-release-sync@0.32.0': + resolution: {integrity: sha512-h9g2Dri6WxKNjX6a1+sFAafOVxj1n5YIA1GAzMoSdIqK1M8cG07tz3GxKVC3Gbdutz14uQ3oc3Q2lll8otInhA==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -632,30 +648,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} @@ -742,36 +763,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.8': resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.8': resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.8': resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.8': resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.8': resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.8': resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} @@ -1406,24 +1433,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -1641,6 +1672,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -2195,6 +2229,12 @@ snapshots: dependencies: hono: 4.13.7 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-singlefile-mjs-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -3143,6 +3183,10 @@ snapshots: queue-microtask@1.2.3: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + range-parser@1.3.0: {} raw-body@3.0.2: