deepseek-harness/packages/workflow/workflow-workerthread/tests/realm.spec.ts

146 lines
6.9 KiB
TypeScript
Raw Normal View History

workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
import { describe, expect, it } from 'vitest'
import * as vm from 'node:vm'
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts'
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
function inRealm(expression: string): unknown {
return vm.runInNewContext(`(${expression})`)
}
/** The MaterializeError message for a value that must be rejected (throws if accepted). */
function rejection(value: unknown): string {
try {
materializeFromRealm(value)
} catch (error: unknown) {
if (error instanceof MaterializeError) return error.message
throw error
}
throw new Error('expected the value to be rejected')
}
describe('materializeFromRealm', () => {
it('copies realm objects/arrays/scalars into host plain data', () => {
const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }")
const out = materializeFromRealm(value) as Record<string, unknown>
expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] })
// The copy is HOST data: prototypes are the host intrinsics.
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
expect(Array.isArray(out.list)).toBe(true)
// And it round-trips through JSON byte-identically (the whole point).
expect(JSON.parse(JSON.stringify(out))).toEqual(out)
})
it('accepts undefined ONLY at the root (a valueless script return)', () => {
expect(materializeFromRealm(undefined)).toBeUndefined()
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
})
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => {
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
const counter = inRealm(`
(() => {
globalThis.reads = 0
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
return { get x() { globalThis.reads += 1; return globalThis.reads } }
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
})()
`)
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
expect(materializeFromRealm(counter)).toEqual({ x: 1 })
})
it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => {
const hostile = inRealm("{ get x() { throw new Error('read failed') } }")
const message = rejection(hostile)
expect(message).toContain('reading the value threw')
expect(message).toContain('read failed')
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
})
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')')
const out = materializeFromRealm(value) as Record<string, unknown>
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
expect(out.ok).toBe(2)
// The host Object.prototype was NOT touched.
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => {
expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn')
expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed')
expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s')
expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big')
expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]')
const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()")
expect(rejection(taggedArray)).toContain('symbol-keyed')
})
it('rejects non-finite numbers and undefined values inside containers', () => {
expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite')
expect(rejection(inRealm('[Infinity]'))).toContain('non-finite')
})
it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => {
expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype')
expect(rejection(inRealm('new Map()'))).toContain('exotic prototype')
expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()')))
.toContain('exotic prototype')
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
})
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
})
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => {
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
.toContain('non-index')
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()')))
.toEqual([7])
workflow: dynamic workflows — script-driven multi-agent orchestration A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line.
2026-07-05 13:29:35 +08:00
})
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
const value = inRealm(`(() => {
const o = { visible: 1 }
Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false })
return o
})()`)
expect(materializeFromRealm(value)).toEqual({ visible: 1 })
})
it('works on plain host values too (the boundary is realm-agnostic)', () => {
expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] })
expect(materializeFromRealm('str')).toBe('str')
expect(materializeFromRealm(3)).toBe(3)
expect(materializeFromRealm(false)).toBe(false)
expect(materializeFromRealm(null)).toBeNull()
})
})
workflow: render thrown script values inside the realm's execution window Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
describe('renderThrown', () => {
it('prefers the stack, for host and realm errors alike', () => {
const host = renderThrown(new Error('host failure'))
expect(host).toContain('host failure')
expect(host).toContain('at ') // a real stack, not just the message
workflow: render thrown script values inside the realm's execution window Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00
const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
expect(renderThrown(realmError)).toContain('realm failure')
workflow: render thrown script values inside the realm's execution window Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00
})
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
it('falls back from stack to message to String()', () => {
expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack')
const stackless = new Error('stackless failure')
delete stackless.stack
expect(renderThrown(stackless)).toBe('stackless failure')
expect(renderThrown({ code: 42 })).toBe('[object Object]')
expect(renderThrown('plain')).toBe('plain')
expect(renderThrown(42)).toBe('42')
expect(renderThrown(undefined)).toBe('undefined')
expect(renderThrown(null)).toBe('null')
workflow: render thrown script values inside the realm's execution window Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00
})
workflow: simplify to the trust premise; settle result on cancellation Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation).
2026-07-06 00:48:49 +08:00
it('is total: a value whose accessors/toString throw renders as a fixed label', () => {
expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
workflow: render thrown script values inside the realm's execution window Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case.
2026-07-05 20:32:35 +08:00
})
})