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.
5.0 KiB
@deepseek-ai/dsh-workflow-vm
The first WorkflowService implementation: an in-process node:vm engine. It parses the Claude Code-format script (export const meta = {...} + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans agent() calls out to ctx.subagents.
The script contract it executes
- Meta extraction (
extractMeta): a string/comment-aware brace scanner finds the leadingexport const metaliteral (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (name/descriptionrequired; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - Hooks:
agent(prompt, {label, phase, schema, model})(schema = the structured-output subset, forwarded asoutputSchema; result = validated object, or final text without a schema; a failed child resolvesnull),parallel(thunks),pipeline(items, ...stages)with NO cross-stage barrier and(prev, item, index)stage callbacks,phase(title),log(message), and theargsglobal. Anything else —effort/isolation/agentType, unknown options, malformed arguments, schemas outside the subset — throws a FATALWorkflowErrorthatparallel/pipelinere-throw rather than nulling (see the seam README's failure discipline). - Determinism bans:
Date.now(),Math.random(), and arglessnew Date()throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context.
Realm discipline
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by materializeFromRealm: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested undefined, and proxies — rejected via the trap-free util.types.isProxy BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via defineProperty so a "__proto__" key becomes a data property, never a prototype mutation. Values ENTERING the realm (args, agent() results) are rebuilt INSIDE the realm through the context's own JSON.parse, and the arrays parallel/pipeline resolve to are realm-built, so the script never holds an object whose prototype chain reaches host intrinsics.
Limits, cancellation, disposal
Per-run: a concurrency semaphore (maxConcurrentAgents), a total-agent() cap (maxTotalAgents), and a per-call item cap (maxItemsPerCall), all config. cancel() aborts every child (a shared AbortSignal), rejects waiting agent() slots, and makes every future hook call throw CANCELLED — the script dies at its next await and the run settles cancelled; a cancellation that lands before the body runs (or before it settles) reports cancelled even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and dispose() waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile stack getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to describeThrown (fixed labels, own-data reads, an identity-verified host-native stack getter) — result cannot reject.
Documented limitations (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm timeout covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; dispose() waits disposeGraceMs then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop).
Config
| Key | Default | Meaning |
|---|---|---|
provider |
spawn |
The ctx.subagents provider children run on. |
maxConcurrentAgents |
0 (auto) |
Concurrent agent() ceiling; 0 resolves to min(16, max(1, cores - 2)). |
maxTotalAgents |
1000 |
Total agent() calls one run may start (runaway-loop backstop). |
maxItemsPerCall |
4096 |
Items accepted by one parallel()/pipeline() call. |
syncTimeoutMs |
5000 |
vm timeout for the initial synchronous slice and the meta evaluation. |
disposeGraceMs |
5000 |
How long dispose() waits for a cancelled script and its children before abandoning them. |