Files
deepseek-harness/packages/workflow/workflow-vm/README.md
Tianyi Cui fff2e1f33d 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

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 leading export const meta literal (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/description required; 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 as outputSchema; result = validated object, or final text without a schema; a failed child resolves null), parallel(thunks), pipeline(items, ...stages) with NO cross-stage barrier and (prev, item, index) stage callbacks, phase(title), log(message), and the args global. Anything else — effort/isolation/agentType, unknown options, malformed arguments, schemas outside the subset — throws a FATAL WorkflowError that parallel/pipeline re-throw rather than nulling (see the seam README's failure discipline).
  • Determinism bans: Date.now(), Math.random(), and argless new 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.