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.
This commit is contained in:
Tianyi Cui
2026-07-05 21:24:30 +08:00
parent 95c8c878e1
commit 7234d41b91
5 changed files with 203 additions and 25 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce
## 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.
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