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
@@ -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