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
@@ -116,6 +116,13 @@ 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', () => {
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]')
})
it('rejects shape violations with EVERY violation listed (META_INVALID)', () => {
const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1')
expect(error.code).toBe('META_INVALID')
@@ -589,6 +589,39 @@ describe('dsh-workflow-vm', () => {
expect(result.error).toBe('[object Object]')
})
it('hostile thrown values render contained: 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.
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 { 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 null', 'null'],
]
for (const [body, rendered] of cases) {
const result = await run(ctx, parent, script(body))
expect(result.stopReason).toBe('error')
expect(result.error).toBe(rendered)
}
// Let any stray rejection reach the process hook before asserting.
await new Promise(resolve => setTimeout(resolve, 20))
expect(unhandled).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
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(`