mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <string> }`, 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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user