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:
8 files changed
+199
-61
No files matched your search
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user