Files
deepseek-harness/packages/workflow/workflow-vm
Tianyi Cui 7234d41b91 workflow: hook promises and hook failures are realm-built too
Codex code-review round 5: agent()/parallel()/pipeline() returned HOST Promise
objects into the script realm — Object.getPrototypeOf(agent('x')) reached host
Promise.prototype, contradicting the realm contract (correctness containment,
not the accepted sandbox stance). The rejection channel had the same leak one
hop away: a caught hook failure was a host WorkflowError (host Error.prototype
chain), and phase()/log() threw host errors synchronously.

All three surfaces are realm-built now:
- hook promises: the realm's own Promise.resolve (bound at context setup)
  assimilates the host promise, so the script-visible promise carries realm
  prototypes; the realm promise gets the same no-op rejection consumer as the
  host one (a script may drop it).
- hook failures: rejections and phase/log sync throws are translated at the
  boundary into realm-built clones (name/code/message/fatal via an in-realm
  factory); non-WorkflowError host failures become generic realm Errors
  carrying their describeThrown rendering.
- the combinators recognize FATAL clones structurally
  (isFatalWorkflowErrorClone: proxy-guarded descriptor reads), preserving the
  fatal-vs-null discipline across the boundary; a script forging the shape
  kills only its own run. drive() maps any post-cancel failure to 'cancelled'
  by run state (a CANCELLED clone deliberately fails the host instanceof).

Tests: realm-promise identity for all three hooks + host Promise.prototype
pollution unreachable; clone shape (instanceof realm Error, name/code/fatal/
message) with prototype-chain mutation staying realm-side; a rejecting
provider result crossing as a generic clone; phase/log sync-throw clones;
combinator catch branches (string throw, proxy throw, shape-miss forgery →
null; forged fatal → kills own run); existing fatal-propagation, cancellation,
and unhandled-rejection tests as canaries.
2026-07-05 21:24:30 +08:00
..

@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 are realm-built throughout, so the script never holds an object whose prototype chain reaches host intrinsics: args and agent() results are rebuilt through the context's own JSON.parse, combinator result arrays through its Array.from, hook promises through its Promise.resolve, and a hook failure (rejection or synchronous phase/log throw) crosses as a realm-built clone carrying name/code/message/fatal — the combinators recognize fatal clones structurally, so the fatal-vs-null discipline survives the boundary.

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 in realm code past that slice (an await continuation, or a thenable's then invoked by promise resolution) 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). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited return agent('x') work — and the realm-boundary guard applies to the resolution.

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.