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:
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user