diff --git a/packages/core/src/agents/runtime/workflow-sandbox.test.ts b/packages/core/src/agents/runtime/workflow-sandbox.test.ts index 73037a81fa4..96c5ad4e40f 100644 --- a/packages/core/src/agents/runtime/workflow-sandbox.test.ts +++ b/packages/core/src/agents/runtime/workflow-sandbox.test.ts @@ -160,6 +160,22 @@ describe('extractAndStripMeta', () => { expect(meta).toEqual({ name: 'demo', description: 'a demo workflow' }); }); + it.each(['--input-type=module', '--experimental-default-type=module'])( + 'ignores inherited NODE_OPTIONS=%s', + (nodeOptions) => { + vi.stubEnv('NODE_OPTIONS', nodeOptions); + try { + const src = `export const meta = { name: 'demo', description: 'a demo workflow' }\nreturn 1`; + expect(extractAndStripMeta(src).meta).toEqual({ + name: 'demo', + description: 'a demo workflow', + }); + } finally { + vi.unstubAllEnvs(); + } + }, + ); + it('extracts optional whenToUse + phases array', () => { const src = `export const meta = { name: 'multi', @@ -231,7 +247,7 @@ describe('extractAndStripMeta', () => { it('rejects meta that references an unknown identifier', () => { const src = `export const meta = { name: totallyUnknown, description: 'd' }\nreturn 1`; expect(() => extractAndStripMeta(src)).toThrow( - /failed to evaluate meta object literal/, + /totallyUnknown is not defined/, ); }); @@ -266,6 +282,451 @@ describe('extractAndStripMeta', () => { expect(() => extractAndStripMeta(src)).toThrow(/unbalanced/i); }); + // The meta literal is model-authored source, and every caller reaches it on + // a path where a wedged thread is unrecoverable: the run path (before the + // sandbox's own 30s body timeout is armed) and, in follow-up work, the tool + // confirmation dialog and the saved-workflow palette. A field value that + // never returns must surface as an ordinary malformed-meta error. + // + // Each case asserts a generous wall-clock bound rather than a precise one, + // so the assertion stays stable on a loaded CI runner. Without the bound + // these hang the worker until vitest's own timeout kills it. + describe('bounded evaluation', () => { + const BOUND_MS = 5_000; + + function timed(fn: () => unknown): number { + const startedAt = Date.now(); + try { + fn(); + } catch { + /* the throw is asserted separately */ + } + return Date.now() - startedAt; + } + + it('bounds a field value that loops on evaluation', () => { + const src = `export const meta = { name: (function () { while (true) {} })(), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds a Promise microtask that loops after literal evaluation', () => { + const src = `export const meta = { name: (Promise.resolve().then(() => { while (true) {} }), 'x'), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds a Promise microtask scheduled by a serializer getter', () => { + const src = `export const meta = { name: 'x', description: 'd', get phases() { Promise.resolve().then(() => { while (true) {} }); return []; } }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + // The case a timeout on the literal's own evaluation does NOT catch: a + // getter defers its work to property-read time, so the literal itself + // evaluates instantly and only spins when the value is walked. Walking on + // the host would run it on the host thread, unbounded. + it('bounds a getter that loops when the value is walked', () => { + const src = `export const meta = { name: 'x', description: 'd', get phases() { while (true) {} } }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds a getter nested inside phases', () => { + const src = `export const meta = { name: 'x', description: 'd', phases: [{ get title() { while (true) {} } }] }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('refuses a meta literal that exceeds the serialized size cap', () => { + const src = `export const meta = { name: 'x'.repeat(200000), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + }); + + it('enforces the cap after JSON escaping', () => { + const src = `export const meta = { name: '\\0'.repeat(20000), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + }); + + it('enforces the cap for string-free containers', () => { + const src = `export const meta = { name: 'x', description: 'd', phases: Array.from({ length: 40000 }, () => ({})) }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + }); + + it('stops walking a holey array after the size budget is exhausted', () => { + const src = `export const meta = { name: 'x', description: 'd', phases: new Array(1e8) }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /meta literal is too large/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds descriptor scanning for a materialized array', () => { + const src = `export const meta = { name: 'x', description: 'd', phases: new Array(10_000_000).fill({}) }\nreturn 1`; + const startedAt = Date.now(); + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(Date.now() - startedAt).toBeLessThan(3_000); + }); + + it('bounds a long native builtin during evaluation', () => { + const src = `export const meta = { name: (new Array(2**26).fill(1).sort(), 'x'), description: 'd' }\nreturn 1`; + const startedAt = Date.now(); + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal/, + ); + expect(Date.now() - startedAt).toBeLessThan(3_000); + }); + + it('bounds a looping then getter', () => { + const src = `export const meta = { name: 'x', description: 'd', extra: { get then() { while (true) {} } } }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds a Promise subclass with a hostile species getter', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + extra: new (class extends Promise { + static get [Symbol.species]() { while (true) {} } + })((resolve) => resolve(1)), + }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to serialize meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds an error message getter that schedules a looping microtask', () => { + const src = `export const meta = { name: (function () { throw { get message() { Promise.resolve().then(() => { while (true) {} }); return 'hostile'; } }; })(), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('bounds an error whose message getter loops', () => { + const src = `export const meta = { name: (function () { throw { get message() { while (true) {} } }; })(), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal/, + ); + expect(timed(() => extractAndStripMeta(src))).toBeLessThan(BOUND_MS); + }); + + it('does not invoke a phases iterator', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + phases: Object.assign([{ title: 'one' }], { + [Symbol.iterator]: function () { while (true) {} }, + }), + }\nreturn 1`; + expect(extractAndStripMeta(src).meta?.phases).toEqual([{ title: 'one' }]); + }); + + it('leaves a well-formed meta literal unaffected', () => { + const src = `export const meta = { name: 'w', description: 'd', phases: [{ title: 'One' }] }\nreturn 1`; + const { meta } = extractAndStripMeta(src); + expect(meta).toEqual({ + name: 'w', + description: 'd', + phases: [{ title: 'One' }], + }); + }); + }); + + it('preserves a thrown string in the evaluation diagnostic', () => { + const src = `export const meta = { name: (function () { throw 'kapow'; })(), description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /failed to evaluate meta object literal: kapow/, + ); + }); + + it.each([ + [ + 'then callback', + `export const meta = { name: (Promise.resolve().then(() => Promise.reject(new Error('boom'))), 'x'), description: 'd' }\nreturn 1`, + ], + [ + 'await continuation', + `export const meta = { name: ((async () => { await 0; Promise.reject(new Error('boom')); })(), 'x'), description: 'd' }\nreturn 1`, + ], + ])('rejects a Promise created by a deferred %s', async (_name, src) => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it.each([ + [ + 'literal timeout', + `export const meta = { name: (function () { Promise.reject(new Error('boom')); while (true) {} })(), description: 'd' }\nreturn 1`, + /failed to evaluate meta object literal/, + ], + [ + 'async literal timeout', + `export const meta = { name: (function () { (async () => { throw new Error('boom'); })(); while (true) {} })(), description: 'd' }\nreturn 1`, + /failed to evaluate meta object literal/, + ], + [ + 'proxy timeout before a sibling rejection', + `export const meta = { name: 'x', description: 'd', blocked: new Proxy({}, { ownKeys() { while (true) {} } }), rejected: Promise.reject(new Error('boom')) }\nreturn 1`, + /failed to serialize meta object literal/, + ], + [ + 'throwing getter after creating a rejection', + `export const meta = { name: 'x', description: 'd', get extra() { Promise.reject(new Error('boom')); throw new Error('getter'); } }\nreturn 1`, + /failed to serialize meta object literal/, + ], + [ + 'looping getter after creating a rejection', + `export const meta = { name: 'x', description: 'd', get extra() { Promise.reject(new Error('boom')); while (true) {} } }\nreturn 1`, + /failed to serialize meta object literal/, + ], + ])( + 'contains rejected Promises when %s aborts extraction', + async (_name, src, error) => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + expect(() => extractAndStripMeta(src)).toThrow(error); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }, + ); + + it.each([ + [ + 'symbol key', + `export const meta = { name: 'x', description: 'd', [Symbol('hidden')]: Promise.reject(new Error('boom')) }\nreturn 1`, + ], + [ + 'prototype', + `export const meta = { __proto__: { hidden: Promise.reject(new Error('boom')) }, name: 'x', description: 'd' }\nreturn 1`, + ], + [ + 'function property', + `export const meta = { name: 'x', description: 'd', extra: Object.assign(function () {}, { hidden: Promise.reject(new Error('boom')) }) }\nreturn 1`, + ], + [ + 'species sabotage', + `export const meta = { name: 'x', description: 'd', extra: (function () { const p = Promise.reject(new Error('boom')); p.constructor = { [Symbol.species]: function () { throw new Error('species'); } }; return p; })() }\nreturn 1`, + ], + [ + 'Promise proxy', + `export const meta = { name: 'x', description: 'd', extra: new Proxy(Promise.reject(new Error('boom')), {}) }\nreturn 1`, + ], + [ + 'non-enumerable property', + `export const meta = { name: 'x', description: 'd', extra: Object.defineProperty({}, 'hidden', { value: Promise.reject(new Error('boom')) }) }\nreturn 1`, + ], + ])('rejects a Promise hidden behind a %s', async (_name, src) => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it('uses the Promise diagnostic for a top-level thenable', () => { + const src = `export const meta = { then: () => {}, name: 'x', description: 'd' }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + }); + + // The literal is evaluated as its own program, so it never shares a lexical + // scope with the serializer that walks it. Interpolating it into the + // serializer's scope would let it read the helpers and overwrite the flag + // that decides whether a thenable was found — i.e. disarm the check that + // keeps a stray rejected Promise from killing the host process. + it('meta source cannot observe or mutate the serializer scope', () => { + const src = `export const meta = { name: String(typeof copy) + ':' + String(typeof hasThenable), description: 'd' }\nreturn 1`; + const { meta } = extractAndStripMeta(src); + expect(meta?.name).toBe('undefined:undefined'); + }); + + it('a thenable stays rejected even when the literal predefines the flag name', () => { + const src = `export const meta = { name: 'x', description: 'd', hasThenable: false, phases: Promise.resolve(1) }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + }); + + it('isolates serializer intrinsics from the meta literal', () => { + const src = `export const meta = { + name: (JSON.stringify = () => ({ toString() { return '@'; } }), 'x'), + description: 'd', + }\nreturn 1`; + expect(extractAndStripMeta(src).meta).toEqual({ + name: 'x', + description: 'd', + }); + }); + + it('rejects Promises after the literal mutates serializer helpers', () => { + const src = `export const meta = { + name: ( + Object.keys = () => ['name', 'description'], + Promise.prototype.then = () => undefined, + 'x' + ), + description: 'd', + extra: Promise.resolve(1), + }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + }); + + it('does not expose host helpers to serializer getters', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + extra: Object.defineProperty({}, 'value', { enumerable: true, get: function () { + const serializerGlobal = arguments.callee.caller.constructor('return globalThis')(); + if (typeof serializerGlobal.copy !== 'undefined') throw new Error('host helper exposed'); + return 'safe'; + } }), + }\nreturn 1`; + expect(extractAndStripMeta(src).meta).toEqual({ + name: 'x', + description: 'd', + }); + }); + + it('rejects serializer-envelope forgery through Object.prototype', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + phases: Promise.resolve(1), + extra: Object.defineProperty({}, 'value', { enumerable: true, get: function () { + const serializerGlobal = arguments.callee.caller.constructor('return globalThis')(); + serializerGlobal.Object.prototype.toJSON = () => ({ + hasThenable: false, + tooLarge: false, + value: { name: 'forged', description: 'forged' }, + }); + return 'safe'; + } }), + }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow(); + }); + + it('preserves phase arrays after attempted Array.prototype poisoning', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + phases: [{ title: 'real' }], + extra: Object.defineProperty({}, 'value', { enumerable: true, get: function () { + const serializerGlobal = arguments.callee.caller.constructor('return globalThis')(); + serializerGlobal.Array.prototype.toJSON = () => [{ title: 'forged' }]; + return 'safe'; + } }), + }\nreturn 1`; + expect(extractAndStripMeta(src).meta?.phases).toEqual([{ title: 'real' }]); + }); + + it('rejects an oversized payload after attempted envelope forgery', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + get whenToUse() { + const serializerGlobal = arguments.callee.caller.constructor('return globalThis')(); + serializerGlobal.Object.prototype.toJSON = () => ({ + hasThenable: false, + tooLarge: false, + value: { name: 'forged', description: 'forged', whenToUse: 'A'.repeat(10 * 1024 * 1024) }, + }); + return 'A'.repeat(10 * 1024 * 1024); + }, + }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow(); + }); + + it('prefers a Promise error after the size budget is exceeded', () => { + const src = `export const meta = { + name: 'x'.repeat(200000), + description: 'd', + extra: Promise.resolve(1), + }\nreturn 1`; + expect(() => extractAndStripMeta(src)).toThrow( + /meta values must not be Promises/, + ); + }); + + it('copies shared phase objects at each array position', () => { + const src = `export const meta = { + name: 'x', + description: 'd', + phases: (function () { + const phase = { title: 'one' }; + return [phase, phase]; + })(), + }\nreturn 1`; + expect(extractAndStripMeta(src).meta?.phases).toEqual([ + { title: 'one' }, + { title: 'one' }, + ]); + }); + + it.each([ + [ + `export const meta = { name: 'x', description: 'd', whenToUse: () => {} }\nreturn 1`, + /meta.whenToUse must be a string/, + ], + [ + `export const meta = { name: 'x', description: 'd', phases: [{ title: 'one', detail: Symbol('x') }] }\nreturn 1`, + /meta.phases\[\].detail must be a string/, + ], + [ + `export const meta = { name: 'x', description: 'd', phases: [{ title: 'one', model: 1n }] }\nreturn 1`, + /meta.phases\[\].model must be a string/, + ], + ])('preserves invalid optional field types for validation', (src, error) => { + expect(() => extractAndStripMeta(src)).toThrow(error); + }); + // P4a adversarial review (HIGH × 3 lenses): the docstring at // workflow-sandbox.ts:283-294 promises the returned meta is HOST-realm — // a per-field copy that defends against T1/T8/T14-style vm-realm escape @@ -298,14 +759,9 @@ describe('extractAndStripMeta', () => { // P4a Round 3 (wenshao): a Promise (e.g. `import('node:fs')`) used as a // value in the meta literal previously crashed the host process. The - // synchronous `runInContext` returns normally with a dangling rejection - // scheduled for the next tick; validateMeta passes (the field isn't on - // the contract surface so it's silently dropped); the workflow even - // returns its result; THEN the unhandled rejection terminates the - // process under Node's default `--unhandled-rejections=throw`. The fix - // is to walk the eval result, neutralise any thenables with a `.catch` - // so they no longer trigger the unhandled-rejection handler, and throw - // an explicit error so the bad meta is rejected up front. + // serializer's handlePromise marks reachable Promises handled, while the + // async-hooks observer catches Promises created on abort paths or outside + // the returned graph. Both paths reject the bad meta up front. it('throws when meta value is a Promise (dynamic import) — no unhandled rejection crash', () => { const src = `export const meta = { name: 'x', description: 'd', extra: import('node:fs') }\nreturn 1`; expect(() => extractAndStripMeta(src)).toThrow( @@ -343,15 +799,9 @@ describe('extractAndStripMeta', () => { expect(sandbox.getPhases()).toEqual(['X', 'Y', 'X']); }); - // P4 Round 4 (wenshao): the R3 thenable walker recursed without a - // seen-guard. A meta literal that builds a cyclic object via spread - // (no getters, no Promises, no exotic constructs — just self-reference) - // overflows the call stack. The walker's RangeError propagates OUT of - // extractAndStripMeta because the try/catch only wraps the vm-eval, so - // the run failure surfaces as `Maximum call stack size exceeded` rather - // than the meta-validation error this guard exists to produce. A - // WeakSet bounds the recursion against cycles AND against future - // shapes where the same node is reached through multiple keys. + // The serializer's ancestors set bounds cycles by removing each node on + // exit. Shared subgraphs are intentionally copied at every array position; + // only nodes on the active recursion path are skipped. it('rejects a cyclic meta value built via spread without stack-overflowing', () => { const src = `export const meta = { name: 'x', diff --git a/packages/core/src/agents/runtime/workflow-sandbox.ts b/packages/core/src/agents/runtime/workflow-sandbox.ts index 2eba8def988..c4c688e93f3 100644 --- a/packages/core/src/agents/runtime/workflow-sandbox.ts +++ b/packages/core/src/agents/runtime/workflow-sandbox.ts @@ -148,6 +148,69 @@ export interface WorkflowMeta { phases?: Array<{ title: string; detail?: string; model?: string }>; } +function evaluateMetaIsolated(metaSource: string): string { + const childEnv = { ...process.env }; + // Parent startup flags can change the evaluator's module mode or preload + // hooks, so the isolated child must start with its declared arguments only. + delete childEnv['NODE_OPTIONS']; + // The inline evaluator has no source coverage to collect; inherited V8 + // coverage only adds cold-start cost and races the parent's coverage files. + delete childEnv['NODE_V8_COVERAGE']; + const result = spawnSync( + process.execPath, + [ + `--max-old-space-size=${META_CHILD_MAX_OLD_SPACE_MB}`, + '--eval', + META_CHILD_SOURCE, + ], + { + input: metaSource, + encoding: 'utf8', + timeout: META_CHILD_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: META_SERIALIZED_MAX_CHARS * 8, + windowsHide: true, + env: childEnv, + }, + ); + const stage = result.stderr.includes(META_CHILD_SERIALIZE_MARKER) + ? 'serialize' + : 'evaluate'; + if (result.error) { + const message = + 'code' in result.error && result.error.code === 'ETIMEDOUT' + ? `isolated evaluator exceeded ${META_CHILD_TIMEOUT_MS}ms` + : result.error.message; + throw new Error( + `extractAndStripMeta: failed to ${stage} meta object literal: ${message}`, + ); + } + let response: { ok: boolean; serialized?: unknown; error?: unknown }; + try { + response = JSON.parse(result.stdout) as typeof response; + } catch { + throw new Error( + `extractAndStripMeta: failed to ${stage} meta object literal: ` + + 'isolated evaluator exited without a result', + ); + } + if (!response.ok) { + throw new Error( + typeof response.error === 'string' + ? response.error + : `extractAndStripMeta: failed to ${stage} meta object literal: ` + + 'unknown error', + ); + } + if (typeof response.serialized !== 'string') { + throw new Error( + 'extractAndStripMeta: failed to serialize meta object literal: ' + + 'unexpected serializer result', + ); + } + return response.serialized; +} + /** * Strip `export const meta = {...}` from the script AND extract the meta * object as a plain host-realm value, ready to surface on `WorkflowRunOutcome`. @@ -155,21 +218,32 @@ export interface WorkflowMeta { * Implementation: * 1. `findMetaBlockBounds` (shared with `stripExportMeta`) locates the * object-literal source range via the brace-walker. - * 2. The literal source is evaluated as `(${metaSource})` inside a fresh - * vm context whose globalThis is a null-prototyped object — no - * bridge to the host realm, no access to host primitives like - * `process` / `require` / the workflow-sandbox bridge globals - * (`args` / `agent` / `phase` / `log` / etc.). The vm realm DOES - * provide its own intrinsics (`Object`, `Array`, `Math`, `Date`, - * `JSON`, …) which is fine: meta extraction is a one-shot at tool- - * invocation time, not replayed during resume, so non-determinism in - * the meta literal (a `Date.now()` call in `meta.name`) does not - * break the resume contract that the script body honors. - * 3. The vm result is walked field-by-field and copied into a new - * host-realm plain object. No JSON round-trip is needed because every - * contract field is a primitive — strings and arrays of plain - * objects with string fields — so prototype identity on the - * intermediate values is irrelevant. + * 2. The literal source is evaluated inside a fresh vm context whose + * globalThis is a null-prototyped object — no bridge to the host + * realm, no access to host primitives like `process` / `require` / + * the workflow-sandbox bridge globals (`args` / `agent` / `phase` / + * `log` / etc.). The vm realm DOES provide its own intrinsics + * (`Object`, `Array`, `Math`, `Date`, `JSON`, …) which is fine: meta + * extraction is a one-shot at tool-invocation time, not replayed + * during resume, so non-determinism in the meta literal (a + * `Date.now()` call in `meta.name`) does not break the resume + * contract that the script body honors. + * 3. A SECOND fresh vm context walks that value and serialises it to JSON, + * and the host parses the result into host-realm plain objects. Its + * intrinsics have never been exposed to the model-authored program. + * + * Both scripts run in a bounded child process. The model-authored literal can + * defer arbitrary work to property-read time — `{ get phases() { while (true) + * {} } }` evaluates instantly and only spins when something reads `.phases`. + * The child boundary lets the host force-stop even native builtins that do not + * reach a `node:vm` interrupt point promptly. + * + * The two scripts must stay separate programs for a second reason: the copy + * helper interpolates only fixed host constants, never model-authored source, + * so the model's literal never shares a lexical scope with it. Interpolating + * the literal into the helper's own scope would let it read and overwrite the + * helper's bindings — including the flag that decides whether a thenable was + * found. * * Returns `{ stripped, meta: null }` when no meta declaration is present * (callers treat this as "no meta"). Throws when meta is present but @@ -188,80 +262,63 @@ export function extractAndStripMeta(source: string): { const stripped = source.slice(0, bounds.exportIdx) + source.slice(bounds.afterMeta); - // Null-prototyped globalThis: no host bridge (no `process` / `require` - // / `args` / workflow-sandbox bridge globals). The vm realm still - // provides its own intrinsics, but that's intentional — see the - // docstring above. - const metaContext = vm.createContext(Object.create(null)); - let raw: unknown; + const serialized = evaluateMetaIsolated(metaSource); + + let walked: { + hasThenable: boolean; + tooLarge: boolean; + value: unknown; + }; try { - raw = new vm.Script(`(${metaSource})`).runInContext(metaContext); + if (typeof serialized !== 'string') { + throw new Error('unexpected serializer result'); + } + if (serialized.length > META_SERIALIZED_MAX_CHARS) { + throw new Error('meta literal is too large'); + } + const parsed: unknown = JSON.parse(serialized); + if ( + parsed === null || + typeof parsed !== 'object' || + typeof (parsed as { hasThenable?: unknown }).hasThenable !== 'boolean' || + typeof (parsed as { tooLarge?: unknown }).tooLarge !== 'boolean' + ) { + throw new Error('unexpected serializer result'); + } + walked = parsed as typeof walked; } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = e instanceof Error ? e.message : 'unknown error'; throw new Error( - `extractAndStripMeta: failed to evaluate meta object literal: ${msg}`, + `extractAndStripMeta: failed to serialize meta object literal: ${msg}`, ); } // P4a R3 (wenshao): a Promise (e.g. `import('node:fs')`) used as a // value in the meta literal would otherwise leave a dangling rejection - // behind — `runInContext` returns synchronously with the Promise scheduled + // behind — evaluation returns synchronously with the Promise scheduled // to reject on the next tick, validateMeta drops the non-contract field // silently, and the run completes successfully. Then Node's default // `--unhandled-rejections=throw` terminates the host process, decoupled - // from the run that triggered it. Walk `raw`, neutralise any thenables - // with `.catch(() => {})` so the rejection is marked handled, and reject - // the meta literal up front. - rejectThenablesInMeta(raw); - - const meta = validateMeta(raw); - return { stripped, meta }; -} - -/** - * Recursively scan a vm-eval'd value, marking any thenable as handled - * (so its rejection cannot terminate the host on the next tick) and - * throwing an explicit "meta values must not be Promises" so the - * malformed meta is reported clearly. - * - * Recurses through plain objects and arrays — `phases[]` entries may - * embed an `import()` below the top level. - */ -function rejectThenablesInMeta( - value: unknown, - seen: WeakSet = new WeakSet(), -): void { - if (value === null || typeof value !== 'object') return; - // P4 Round 4 (wenshao): a cyclic meta literal built via spread of a - // self-referential object would otherwise overflow the call stack on - // this walk — the walker exists to reject Promises before they leave - // a dangling rejection, but the walk itself must terminate on any - // shape vm-eval can return. Track visited nodes in a WeakSet so cycles - // and shared subgraphs both early-return without re-walking. - if (seen.has(value as object)) return; - seen.add(value as object); - const maybeThen = (value as { then?: unknown }).then; - if (typeof maybeThen === 'function') { - // Mark handled so Node's unhandled-rejection trap does not later kill - // the process. `.catch` on a non-Promise thenable would synchronously - // throw if the implementation is non-standard, so swallow defensively. - try { - (value as Promise).catch(() => {}); - } catch { - /* non-standard thenable — already rejecting below */ - } + // from the run that triggered it. The serializer marks any thenable it + // reaches as handled inside the vm; reject the meta literal up front. + if (walked.hasThenable) { + throw new Error(META_PROMISE_ERROR); + } + if (walked.tooLarge) { throw new Error( - 'extractAndStripMeta: meta values must not be Promises ' + - '(no async / dynamic import allowed in meta literal)', + 'extractAndStripMeta: failed to serialize meta object literal: ' + + 'meta literal is too large', ); } - if (Array.isArray(value)) { - for (const v of value) rejectThenablesInMeta(v, seen); - return; - } - for (const v of Object.values(value as Record)) { - rejectThenablesInMeta(v, seen); + if (!Object.hasOwn(walked, 'value')) { + throw new Error( + 'extractAndStripMeta: failed to serialize meta object literal: ' + + 'unexpected serializer result', + ); } + + const meta = validateMeta(walked.value); + return { stripped, meta }; } /** @@ -361,6 +418,7 @@ function isRegexContext(source: string, i: number): boolean { return /[{[(,;:=!&|?+\-*/%^~<>]/.test(prev); } +import { spawnSync } from 'node:child_process'; import * as vm from 'node:vm'; import { createDebugLogger } from '../../utils/debugLogger.js'; import type { WorkflowDispatchScheduler } from './workflow-dispatch-scheduler.js'; @@ -379,6 +437,326 @@ const MAX_PHASE_ENTRIES = 10_000; // nested model-authored input. const ARGS_MAX_DEPTH = 64; +// Per-script timeout inside the isolated evaluator. The child has a separate +// outer timeout because V8 cannot interrupt one long native builtin promptly. +const META_EVAL_TIMEOUT_MS = 250; +const META_CHILD_TIMEOUT_MS = 2_000; +// Prevent allocation-heavy literals from exhausting the host process before +// the child timeout can stop them. +const META_CHILD_MAX_OLD_SPACE_MB = 256; +const META_CHILD_SERIALIZE_MARKER = '__qwen_meta_serialize__'; + +// Cap on the serialised meta payload. Bounds what a literal can force the +// host to retain and re-parse — a 250ms budget is enough to allocate a very +// large string, and the JSON round-trip would then copy it twice more. +const META_SERIALIZED_MAX_CHARS = 64 * 1024; +const META_PROMISE_ERROR = + 'extractAndStripMeta: meta values must not be Promises ' + + '(no async / dynamic import allowed in meta literal)'; + +// Context slot that carries the evaluated literal from script 1 to script 2. +const META_SLOT = '__qwenWorkflowMetaValue'; +const META_ERROR_SLOT = '__qwenWorkflowMetaError'; + +const META_ERROR_SOURCE = `(() => { + const apply = Reflect.apply; + const get = Reflect.get; + const slice = String.prototype.slice; + const error = globalThis[${JSON.stringify(META_ERROR_SLOT)}]; + if (typeof error === 'string') return apply(slice, error, [0, 1000]); + if (error === null || (typeof error !== 'object' && typeof error !== 'function')) { + return 'unknown error'; + } + try { + const message = get(error, 'message'); + return typeof message === 'string' + ? apply(slice, message, [0, 1000]) + : 'unknown error'; + } catch { + return 'unknown error'; + } +})()`; + +/** + * Fixed source for the meta serializer. Interpolates only fixed host constants + * (the size cap and slot name), never model-authored source. The model's + * literal is evaluated by a separate program (see `extractAndStripMeta`), so + * it never shares a lexical scope with these bindings and cannot read or + * overwrite `hasThenable`, `copy`, or the visited set. + * + * Runs inside the vm so that property reads on the literal — getters, proxy + * traps — execute under the same timeout as the literal itself. + * + * Intrinsics are captured from this fresh realm and every call goes through + * `Reflect.apply`, so model-authored prototype mutations cannot redirect the + * walk. + */ +const META_SERIALIZE_SOURCE = `(() => { + Object.freeze(Object.prototype); + Object.freeze(Array.prototype); + const apply = Reflect.apply; + const isArray = Array.isArray; + const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; + const getPrototypeOf = Object.getPrototypeOf; + const objectKeys = Object.keys; + const objectCreate = Object.create; + const ownKeys = Reflect.ownKeys; + const jsonStringify = JSON.stringify; + const push = Array.prototype.push; + const thenCall = Promise.prototype.then; + const setAdd = WeakSet.prototype.add; + const setDelete = WeakSet.prototype.delete; + const setHas = WeakSet.prototype.has; + const ancestors = new WeakSet(); + const promiseScanSeen = new WeakSet(); + const handledPromises = new WeakSet(); + let hasThenable = false; + let tooLarge = false; + let budget = ${META_SERIALIZED_MAX_CHARS}; + + function spend(n) { + if (tooLarge) return false; + budget -= n; + if (budget < 0) { + tooLarge = true; + return false; + } + return true; + } + + function handlePromise(value) { + if (apply(setHas, handledPromises, [value])) return true; + try { + apply(thenCall, value, [undefined, () => {}]); + } catch (e) { + return false; + } + apply(setAdd, handledPromises, [value]); + hasThenable = true; + return true; + } + + function markPromises(value) { + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return; + } + if (handlePromise(value)) return; + try { + if (typeof value.then === 'function') { + hasThenable = true; + return; + } + } catch { + // Keep scanning data properties after a hostile then getter throws. + } + if (apply(setHas, promiseScanSeen, [value])) return; + apply(setAdd, promiseScanSeen, [value]); + if (isArray(value) && value.length * 2 > budget) { + tooLarge = true; + return; + } + const descriptors = getOwnPropertyDescriptors(value); + const keys = ownKeys(descriptors); + for (let i = 0; i < keys.length; i++) { + const descriptor = descriptors[keys[i]]; + if ('value' in descriptor) markPromises(descriptor.value); + } + markPromises(getPrototypeOf(value)); + } + + function copy(value) { + if (tooLarge) return undefined; + const type = typeof value; + if (value === null) return null; + if (type === 'string') return spend(value.length) ? value : undefined; + if (type === 'number' || type === 'boolean') { + return spend(8) ? value : undefined; + } + if (type === 'undefined') return undefined; + if (type !== 'object') return null; + if (handlePromise(value) || typeof value.then === 'function') { + hasThenable = true; + return undefined; + } + if (apply(setHas, ancestors, [value])) return undefined; + apply(setAdd, ancestors, [value]); + try { + if (isArray(value)) { + const out = []; + for (let i = 0; i < value.length; i++) { + if (tooLarge) break; + const keep = spend(2); + const copied = copy(value[i]); + if (keep) apply(push, out, [copied]); + } + return out; + } + const out = objectCreate(null); + const keys = objectKeys(value); + for (let i = 0; i < keys.length; i++) { + if (tooLarge) break; + const key = keys[i]; + const keep = spend(key.length + 4); + const copied = copy(value[key]); + if (keep) out[key] = copied; + } + return out; + } finally { + apply(setDelete, ancestors, [value]); + } + } + + const source = globalThis[${JSON.stringify(META_SLOT)}]; + markPromises(source); + const value = copy(source); + let serialized = jsonStringify({ + hasThenable: hasThenable, + tooLarge: tooLarge, + value: value, + }); + if (serialized.length > ${META_SERIALIZED_MAX_CHARS}) { + tooLarge = true; + serialized = jsonStringify({ + hasThenable: hasThenable, + tooLarge: tooLarge, + value: null, + }); + } + return serialized; +})()`; + +const META_CHILD_SOURCE = ` +const { createHook } = require('node:async_hooks'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const META_EVAL_TIMEOUT_MS = ${META_EVAL_TIMEOUT_MS}; +const META_PROMISE_ERROR = ${JSON.stringify(META_PROMISE_ERROR)}; +const META_SLOT = ${JSON.stringify(META_SLOT)}; +const META_ERROR_SLOT = ${JSON.stringify(META_ERROR_SLOT)}; +const META_ERROR_SOURCE = ${JSON.stringify(META_ERROR_SOURCE)}; +const META_SERIALIZE_SOURCE = ${JSON.stringify(META_SERIALIZE_SOURCE)}; +const META_CHILD_SERIALIZE_MARKER = ${JSON.stringify(META_CHILD_SERIALIZE_MARKER)}; + +process.on('unhandledRejection', () => {}); + +function createMetaContext() { + return vm.createContext(Object.create(null), { + microtaskMode: 'afterEvaluate', + }); +} + +function drainMetaMicrotasks(context) { + new vm.Script('void 0').runInContext(context, { + timeout: META_EVAL_TIMEOUT_MS, + }); +} + +function formatMetaEvaluationError(error) { + const context = createMetaContext(); + Object.defineProperty(context, META_ERROR_SLOT, { value: error }); + try { + const message = new vm.Script(META_ERROR_SOURCE).runInContext(context, { + timeout: META_EVAL_TIMEOUT_MS, + }); + return typeof message === 'string' ? message : 'unknown error'; + } catch { + return 'unknown error'; + } +} + +function metaStageError(stage, error, metaContext) { + let message = formatMetaEvaluationError(error); + try { + drainMetaMicrotasks(metaContext); + } catch (drainError) { + message = formatMetaEvaluationError(drainError); + } + return new Error( + 'extractAndStripMeta: failed to ' + stage + + ' meta object literal: ' + message, + ); +} + +function observePromiseRejections(run) { + const nativeThen = Promise.prototype.then; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const defineProperty = Object.defineProperty; + const deleteProperty = Reflect.deleteProperty; + let attaching = false; + let createdPromise = false; + const hook = createHook({ + init(_asyncId, type, _triggerAsyncId, resource) { + if (type !== 'PROMISE' || attaching) return; + createdPromise = true; + attaching = true; + const ownConstructor = getOwnPropertyDescriptor(resource, 'constructor'); + try { + defineProperty(resource, 'constructor', { + value: Promise, + configurable: true, + }); + Reflect.apply(nativeThen, resource, [undefined, () => undefined]); + } catch { + // Non-native thenables are rejected by the bounded serializer. + } finally { + if (ownConstructor) { + defineProperty(resource, 'constructor', ownConstructor); + } else { + Reflect.apply(deleteProperty, Reflect, [resource, 'constructor']); + } + attaching = false; + } + }, + }); + hook.enable(); + try { + const value = run(); + if (createdPromise) throw new Error(META_PROMISE_ERROR); + return value; + } finally { + hook.disable(); + } +} + +try { + const metaSource = fs.readFileSync(0, 'utf8'); + const metaContext = createMetaContext(); + const serialized = observePromiseRejections(() => { + let raw; + try { + raw = new vm.Script('(' + metaSource + ')').runInContext(metaContext, { + timeout: META_EVAL_TIMEOUT_MS, + }); + } catch (error) { + throw metaStageError('evaluate', error, metaContext); + } + + fs.writeSync(2, META_CHILD_SERIALIZE_MARKER); + try { + const serializeContext = createMetaContext(); + Object.defineProperty(serializeContext, META_SLOT, { value: raw }); + const output = new vm.Script(META_SERIALIZE_SOURCE).runInContext( + serializeContext, + { timeout: META_EVAL_TIMEOUT_MS }, + ); + drainMetaMicrotasks(metaContext); + return output; + } catch (error) { + throw metaStageError('serialize', error, metaContext); + } + }); + process.stdout.write(JSON.stringify({ ok: true, serialized })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : 'unknown error', + })); +} +`; + /** * WorkflowAgentOpts — structured options for the `agent()` global. *