diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index c1fbbe7427..6f8678ecea 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -30,7 +30,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.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 via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. -**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script values (and meta-evaluation throws) are rendered by the total `describeThrown` — fixed labels for proxies/functions, own-descriptor reads, `String()` only on primitives, and a CONTAINED stack-getter call (modern V8 makes `stack` an own accessor on real Errors; a hostile getter's throw is swallowed) — so a hostile thrown value can neither escape the catch path raw nor make `result` reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script/meta values are pre-rendered to a string by a realm-side catch compiled into the wrapper (rendering runs inside the realm's own execution window, so a hostile `stack` getter dies by the vm sync-slice timeout like any other script code — host-side formatting of realm errors is unfixable in general, since V8 stack formatting invokes script-controllable `name`/`prepareStackTrace` hooks); the host catch descriptor-reads that string or falls back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 1efe4eb5af..e519ce7c0e 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -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), 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. +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). diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts index edf95eb22c..bcb891e85e 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -20,7 +20,7 @@ import * as vm from 'node:vm' import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' /** The result of {@link extractMeta}: the validated meta and the runnable body. */ export interface ExtractedScript { @@ -171,13 +171,21 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr // An EMPTY context: any non-literal reference (a variable, a call) throws // here. The result — data only — is what the contract checks; a getter or // IIFE can still run, which is why the timeout and the materialization - // below are part of the same boundary. - evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) + // below are part of the same boundary. A thrown value is pre-rendered by + // the realm-side catch INSIDE the timed window, so a hostile + // stack/message/toString can neither run on the host catch path nor + // outlive the timeout. + evaluated = vm.runInNewContext( + `(() => { try { return (${literal}) } catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, + undefined, + { timeout: evalTimeoutMs }, + ) } catch (error: unknown) { - // describeThrown, not String(): an expression in the literal can THROW a - // hostile value (a throwing toString/accessor), and this catch must map - // it to META_INVALID rather than let realm code run or a raw error escape. - throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${describeThrown(error)}`, 'META_INVALID', { cause: error }) + throw new WorkflowError( + `meta block failed to evaluate as a pure literal: ${thrownRendering(error) ?? describeThrown(error)}`, + 'META_INVALID', + { cause: error }, + ) } let data: unknown try { diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index b1389a999c..fca93caac4 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -27,10 +27,16 @@ * chain, so the engine rebuilds inbound values INSIDE the realm via the * context's own `JSON.parse` (see the runtime). * - * {@link describeThrown} is the same discipline for the one place realm - * values reach the host WITHOUT materialization: rendering a thrown value - * for a failure report. It never throws; the only realm code it can invoke - * is a stack getter, contained (see its doc). + * {@link REALM_THROWN_RENDERER_SOURCE}, {@link thrownRendering}, and + * {@link describeThrown} are the same discipline for the one place realm + * values reach the host WITHOUT materialization: a thrown value crossing into + * a host catch block. The renderer runs INSIDE the realm's own execution + * window (compiled into the script wrapper), so reading a hostile + * accessor/`toString` there is subject to the vm sync-slice timeout exactly + * like any other script code; the host side only descriptor-reads the + * pre-rendered string, or falls back to {@link describeThrown}, which invokes + * no getter whose function identity is not the host realm's own native stack + * getter. * * @module @deepseek-ai/dsh-workflow-vm/realm */ @@ -46,22 +52,66 @@ export class MaterializeError extends Error { } /** - * Render a value THROWN by realm code (a script failure, a meta-literal - * evaluation failure) as text, without ever throwing itself — the callers sit - * in catch blocks whose totality is a seam contract (`WorkflowRun.result` - * never rejects). Plain property reads and `String(value)` are hostile-value - * hazards (`{ get stack() { throw ... } }`, a throwing - * `toString`/`Symbol.toPrimitive`), so: proxies render as a fixed label - * (trap-free `isProxy`, before any inspection); `message` is read as an OWN - * DATA descriptor only; everything else object-shaped renders as - * `[object Object]` without being touched; only primitives (which cannot - * carry code) reach `String()`. The one exception is the `stack` getter — - * modern V8 makes `stack` an own ACCESSOR on genuine `Error`s, so it is - * invoked (that is how real stacks, with the script's own line numbers via - * the compile lineOffset, are obtained) but CONTAINED: a hostile getter's - * throw is swallowed and rendering falls back to message. Detection is - * structural, not `instanceof` — a realm Error is not an instance of the host - * class. + * Realm-SOURCE text (an arrow-function expression) the engine compiles into + * its script wrappers: `throw (RENDERER)(e)` inside a catch around the whole + * body/literal. It renders the thrown value to a string INSIDE the realm's + * own execution window — a hostile `stack`/`message` accessor or `toString` + * invoked here is subject to the vm sync-slice timeout like any other script + * code (and post-await it is the engine's accepted spin limitation, identical + * to a script reading `e.stack` in its own catch). Host `WorkflowError`s + * thrown by hooks pass through unwrapped (duck-checked by name — a realm + * forgery fails the host's `instanceof` and merely renders data-only); + * everything else becomes `{ __wfThrown: }`, whose only consumer is + * {@link thrownRendering}. Every read is individually contained, so the + * renderer itself never throws. + */ +export const REALM_THROWN_RENDERER_SOURCE = `(e) => { + try { if (e && e.name === 'WorkflowError') return e } catch { /* hostile name getter: fall through to rendering */ } + const rendered = (() => { + try { if (e && typeof e.stack === 'string' && e.stack.length > 0) return e.stack } catch { /* hostile stack getter */ } + try { if (e && typeof e.message === 'string') return e.message } catch { /* hostile message getter */ } + try { return String(e) } catch { /* hostile toString/Symbol.toPrimitive */ } + return '[unrenderable thrown value]' + })() + return { __wfThrown: rendered } +}` + +/** + * The pre-rendered failure text carried by a realm-catch wrapper object + * (`{ __wfThrown: string }` from {@link REALM_THROWN_RENDERER_SOURCE}), or + * `undefined` when `error` is not such a wrapper. Descriptor-read and + * proxy-guarded: never invokes user code. + * @param error - the value a host catch received from script execution. + * @returns the realm-rendered string, or `undefined` to fall back to + * {@link describeThrown}. + */ +export function thrownRendering(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || types.isProxy(error)) return undefined + const value = ownDataProperty(error, '__wfThrown') + return typeof value === 'string' ? value : undefined +} + +/** + * The host realm's own native `stack` getter (modern V8 makes `stack` an own + * ACCESSOR on Errors); `undefined` where it is a data property. Typed through + * a structural view of the descriptor — it is only ever identity-compared or + * `.call`ed on an explicit receiver, never invoked unbound. + */ +const HOST_STACK_GETTER: unknown = (Object.getOwnPropertyDescriptor(new Error(), 'stack') as { get?: unknown } | undefined)?.get + +/** + * Render a thrown value HOST-SIDE without ever throwing and without running + * any code the host does not own: proxies become a fixed label (trap-free + * `isProxy` before any inspection); `stack` is read as an own data descriptor, + * or through its getter ONLY when that getter's function identity is the host + * realm's own native stack getter (an unforgeable check — realm code cannot + * hold that identity, and the host realm's `prepareStackTrace` is the host's + * own trust domain); `message` is an own-data read; anything else + * object-shaped renders as `[object Object]` untouched; only primitives + * (which cannot carry code) reach `String()`. Used for host-thrown errors + * (vm timeouts, `WorkflowError`s) and as the fallback for adversarial values + * that bypassed the realm-side renderer (e.g. a hostile thenable rejection); + * ordinary script failures arrive pre-rendered via {@link thrownRendering}. * @param error - the thrown value, of any shape and any realm. * @returns human-readable text for the failure report; prefers the stack. */ @@ -86,24 +136,18 @@ export function describeThrown(error: unknown): string { } /** - * Read `error.stack`, tolerating both descriptor shapes: an own DATA property - * (older V8, plain objects) and the modern own ACCESSOR pair (the Error Stack - * Accessor proposal). Invoking the getter is the only way to obtain a real - * stack; on a hostile object that getter is user code, so the call is - * contained — a throw yields `undefined` (the caller falls back to message), - * and a synchronous spin is the engine's already-accepted post-await - * limitation (a script can spin directly just the same). + * Read `error.stack` without running foreign code: an own DATA descriptor is + * read directly; an accessor is invoked only on function identity with + * {@link HOST_STACK_GETTER} (never a realm or user function). The native + * getter returns `undefined` on a non-Error receiver rather than throwing. */ function readStack(error: object): unknown { const descriptor = Object.getOwnPropertyDescriptor(error, 'stack') if (descriptor === undefined) return undefined if ('value' in descriptor) return descriptor.value if (typeof descriptor.get !== 'function') return undefined - try { - return descriptor.get.call(error) - } catch { - return undefined // a hostile stack getter threw; message/fallback renders instead - } + if (descriptor.get !== HOST_STACK_GETTER) return undefined + return descriptor.get.call(error) } /** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */ diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index c108d2cf69..892b7c781d 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -41,7 +41,7 @@ import type { WorkflowMeta, WorkflowResult, } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' /** The per-run knobs the engine resolves from its Config. */ export interface ExecutionLimits { @@ -137,13 +137,19 @@ export class WorkflowExecution { ) { // Compile FIRST: a body syntax error must throw out of the constructor // (the engine maps it to SCRIPT_PARSE) before any realm state exists. + // The body is wrapped in a realm-side catch that pre-renders any thrown + // value to a string (see REALM_THROWN_RENDERER_SOURCE) — rendering happens + // inside the realm's own execution window, never on a host catch path. // lineOffset compensates for the wrapper line, so stack traces carry the // script's own line numbers (the meta statement was blanked, not removed). try { - this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { - filename: `workflow:${meta.name}`, - lineOffset: -1, - }) + this.compiled = new vm.Script( + `(async () => { try {\n${body}\n} catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, + { + filename: `workflow:${meta.name}`, + lineOffset: -1, + }, + ) } catch (error: unknown) { throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) } @@ -225,10 +231,13 @@ export class WorkflowExecution { if (error instanceof WorkflowError && error.code === 'CANCELLED') { return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started } } - // describeThrown is total and trap-free: a hostile thrown value (a - // throwing accessor, a proxy) cannot make this catch throw — drive() - // resolving is the `result` never-rejects seam contract. - return { value: null, stopReason: 'error', error: describeThrown(error), agentsStarted: this.started } + // Ordinary script failures arrive pre-rendered by the realm-side catch + // (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError) + // and adversarial values that bypassed the wrapper (e.g. a hostile + // thenable rejection) render via the total, host-code-only + // describeThrown. Neither path can throw — drive() resolving is the + // `result` never-rejects seam contract. + return { value: null, stopReason: 'error', error: thrownRendering(error) ?? describeThrown(error), agentsStarted: this.started } } finally { // Reap strays: a script that fired agent() calls without awaiting them // leaves live children behind after settlement — abort them all. (The diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index 7b5a929046..a957b0ae05 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -116,11 +116,24 @@ return 2` expect(error.message).toContain('proxies cannot cross') }) - it('a meta expression THROWING a hostile value maps to META_INVALID — rendering runs no realm code', () => { + it('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => { + // bad() rethrows anything that is not a WorkflowError, so a hostile value + // escaping the realm-side renderer raw would fail this test. const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1') expect(error.code).toBe('META_INVALID') expect(error.message).toContain('pure literal') - expect(error.message).toContain('[object Object]') + expect(error.message).toContain('[unrenderable thrown value]') + }) + + it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => { + try { + extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50) + throw new Error('expected the extraction to time out') + } catch (error: unknown) { + expect(error).toBeInstanceOf(WorkflowError) + expect((error as WorkflowError).code).toBe('META_INVALID') + expect((error as WorkflowError).message.toLowerCase()).toContain('timed out') + } }) it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-vm/tests/realm.spec.ts index 86deb49bd3..c1bdeb33d6 100644 --- a/packages/workflow/workflow-vm/tests/realm.spec.ts +++ b/packages/workflow/workflow-vm/tests/realm.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import * as vm from 'node:vm' -import { materializeFromRealm, MaterializeError } from '../src/realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts' /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */ function inRealm(expression: string): unknown { @@ -133,3 +133,46 @@ describe('materializeFromRealm', () => { expect(materializeFromRealm(null)).toBeNull() }) }) + +describe('describeThrown (host-side thrown-value rendering)', () => { + it('renders a HOST Error via its identity-verified native stack getter', () => { + const error = new Error('host failure') + const rendered = describeThrown(error) + expect(rendered).toContain('host failure') + expect(rendered).toContain('at ') // a real stack, not just the message + }) + + it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => { + const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()') + expect(describeThrown(realmError)).toBe('realm failure') + }) + + it('reads a data-property stack directly and falls through a setter-only accessor', () => { + expect(describeThrown({ stack: 'data stack' })).toBe('data stack') + const setterOnly = { message: 'via message' } + Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } }) + expect(describeThrown(setterOnly)).toBe('via message') + }) + + it('labels proxies and functions without touching them; primitives stringify', () => { + expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]') + expect(describeThrown(() => 1)).toBe('[thrown function]') + expect(describeThrown('plain')).toBe('plain') + expect(describeThrown(42)).toBe('42') + expect(describeThrown(undefined)).toBe('undefined') + expect(describeThrown(null)).toBe('null') + expect(describeThrown({ code: 42 })).toBe('[object Object]') + }) +}) + +describe('thrownRendering (the realm-catch wrapper reader)', () => { + it('extracts the pre-rendered string from a wrapper and nothing else', () => { + expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text') + expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined() + expect(thrownRendering({ other: 'x' })).toBeUndefined() + expect(thrownRendering(new Error('plain'))).toBeUndefined() + expect(thrownRendering('string')).toBeUndefined() + expect(thrownRendering(null)).toBeUndefined() + expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined() + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index e0518c8192..00a9438575 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -589,24 +589,24 @@ describe('dsh-workflow-vm', () => { expect(result.error).toBe('[object Object]') }) - it('hostile thrown values render contained: result NEVER rejects, no unhandled rejection', async () => { + it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => { const unhandled: unknown[] = [] const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } process.on('unhandledRejection', onUnhandled) try { const { ctx, parent } = await setup() - // Each thrown value would run realm code (or throw) under a plain - // property read or String(); rendering must stay total — the only - // permitted realm call is the CONTAINED stack getter. + // Each thrown value runs code (or throws) when rendered — the realm + // wrapper renders it INSIDE script execution, and the host catch only + // ever descriptor-reads the pre-rendered string. const cases: [string, string][] = [ ["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'], ["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'], - ["throw { get message() { throw new Error('message getter ran') } }", '[object Object]'], + ["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'], ["throw { stack: 'custom data stack' }", 'custom data stack'], ["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'], - ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })", '[thrown proxy]'], - ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive ran') } }", '[object Object]'], - ['throw () => 1', '[thrown function]'], + ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'], + ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'], + ['throw () => 1', '() => 1'], ['throw null', 'null'], ] for (const [body, rendered] of cases) { @@ -622,6 +622,27 @@ describe('dsh-workflow-vm', () => { } }) + it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) + // The realm-side renderer reads e.stack INSIDE the timed sync slice, so + // the spin is killed exactly like a plain `while (true) {}` body. + const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }')) + expect(result.stopReason).toBe('error') + expect(result.error?.toLowerCase()).toContain('timed out') + }) + + it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => { + const { ctx, parent } = await setup() + // Returning a thenable makes the host unwrap it AFTER the script + // settled — its rejection value skips the realm catch entirely and hits + // drive()'s catch raw. The proxy must be labelled, its traps never run. + const result = await run(ctx, parent, script(` + return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } } + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toBe('[thrown proxy]') + }) + it('falls back to the message for an Error whose stack was stripped', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(`