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