workflow: total, contained rendering of hostile thrown script values

Codex code-review round 2: errorText() read .stack/.message as plain property
gets and fell back to String(error) — a script throwing a value with a
throwing accessor (or toString/Symbol.toPrimitive) ran realm code in drive()'s
catch and made WorkflowRun.result REJECT, which the detached workflow/end hook
turned into an unhandledRejection (process death under dsh-app-boot).

Replaced with describeThrown in dsh-workflow-vm/realm: total (never throws),
proxy-labelling before any inspection, own-descriptor reads, String() only on
primitives, and a CONTAINED stack-getter invocation — modern V8 (Node >= 22)
makes stack an own ACCESSOR on genuine Errors, so refusing all accessors would
lose every real stack and the lineOffset line numbers; a hostile getter's
throw is swallowed and rendering falls back to message. The meta-literal
eval catch had the same String(error) exposure and now uses the same renderer.

Regression tests: a hostile-thrown-values table through the real engine
(throwing stack/message getters, data stack, setter-only stack, proxy,
Symbol.toPrimitive, function, null) asserting result resolves 'error' with the
expected rendering and NO unhandledRejection fires; a meta-path hostile throw
mapping to META_INVALID.
This commit is contained in:
Tianyi Cui
2026-07-05 19:39:19 +08:00
parent e264a106fd
commit 57b9910339
7 changed files with 124 additions and 21 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Values ENTERING the host (the meta literal, hook options/schemas, the script's r
## 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).
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), and thrown script values are rendered by the total `describeThrown` (proxy-labelling, own-descriptor reads, a contained stack-getter call), so a hostile throw (`{ get stack() { throw ... } }`) cannot make `result` 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).