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

View File

@@ -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 } from './realm.ts'
import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts'
/** The result of {@link extractMeta}: the validated meta and the runnable body. */
export interface ExtractedScript {
@@ -174,7 +174,10 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr
// below are part of the same boundary.
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
} catch (error: unknown) {
throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${String(error)}`, 'META_INVALID', { cause: error })
// 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 })
}
let data: unknown
try {

View File

@@ -27,6 +27,11 @@
* 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).
*
* @module @deepseek-ai/dsh-workflow-vm/realm
*/
@@ -40,6 +45,73 @@ 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.
* @param error - the thrown value, of any shape and any realm.
* @returns human-readable text for the failure report; prefers the stack.
*/
export function describeThrown(error: unknown): string {
switch (typeof error) {
case 'object':
break
case 'function':
return '[thrown function]'
default:
// Primitives (string/number/boolean/bigint/symbol/undefined): String()
// cannot reach user code on these.
return String(error)
}
if (error === null) return 'null'
if (types.isProxy(error)) return '[thrown proxy]'
const stack = readStack(error)
if (typeof stack === 'string' && stack.length > 0) return stack
const message = ownDataProperty(error, 'message')
if (typeof message === 'string') return message
return '[object Object]'
}
/**
* 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).
*/
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
}
}
/** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */
function ownDataProperty(value: object, key: string): unknown {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
}
/**
* Whether an object's prototype chain is data-shaped: `null`, or a prototype
* whose own prototype is `null` (the realm's `Object.prototype` — which we

View File

@@ -41,7 +41,7 @@ import type {
WorkflowMeta,
WorkflowResult,
} from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError } from './realm.ts'
import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts'
/** The per-run knobs the engine resolves from its Config. */
export interface ExecutionLimits {
@@ -97,21 +97,6 @@ function outputText(blocks: ContentBlock[]): string {
.join('')
}
/**
* Render a script failure for the result: prefer the stack (it carries the
* script's own line numbers via the compile lineOffset), then the message.
* STRUCTURAL detection, not `instanceof Error` — a realm-thrown Error is not
* an instance of the host Error class.
*/
function errorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const maybe = error as { stack?: unknown; message?: unknown }
if (typeof maybe.stack === 'string' && maybe.stack.length > 0) return maybe.stack
if (typeof maybe.message === 'string') return maybe.message
}
return String(error)
}
/** A short display label derived from the prompt when the script passes none. */
function defaultLabel(prompt: string): string {
const newline = prompt.indexOf('\n')
@@ -240,7 +225,10 @@ export class WorkflowExecution {
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
}
return { value: null, stopReason: 'error', error: errorText(error), 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 }
} finally {
// Reap strays: a script that fired agent() calls without awaiting them
// leaves live children behind after settlement — abort them all. (The