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.
This commit is contained in:
Tianyi Cui
2026-07-05 20:32:35 +08:00
parent 57b9910339
commit fff2e1f33d
8 changed files with 199 additions and 61 deletions

View File

@@ -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()
})
})