workflow: hook promises and hook failures are realm-built too
Codex code-review round 5: agent()/parallel()/pipeline() returned HOST Promise
objects into the script realm — Object.getPrototypeOf(agent('x')) reached host
Promise.prototype, contradicting the realm contract (correctness containment,
not the accepted sandbox stance). The rejection channel had the same leak one
hop away: a caught hook failure was a host WorkflowError (host Error.prototype
chain), and phase()/log() threw host errors synchronously.
All three surfaces are realm-built now:
- hook promises: the realm's own Promise.resolve (bound at context setup)
assimilates the host promise, so the script-visible promise carries realm
prototypes; the realm promise gets the same no-op rejection consumer as the
host one (a script may drop it).
- hook failures: rejections and phase/log sync throws are translated at the
boundary into realm-built clones (name/code/message/fatal via an in-realm
factory); non-WorkflowError host failures become generic realm Errors
carrying their describeThrown rendering.
- the combinators recognize FATAL clones structurally
(isFatalWorkflowErrorClone: proxy-guarded descriptor reads), preserving the
fatal-vs-null discipline across the boundary; a script forging the shape
kills only its own run. drive() maps any post-cancel failure to 'cancelled'
by run state (a CANCELLED clone deliberately fails the host instanceof).
Tests: realm-promise identity for all three hooks + host Promise.prototype
pollution unreachable; clone shape (instanceof realm Error, name/code/fatal/
message) with prototype-chain mutation staying realm-side; a rejecting
provider result crossing as a generic clone; phase/log sync-throw clones;
combinator catch branches (string throw, proxy throw, shape-miss forgery →
null; forged fatal → kills own run); existing fatal-propagation, cancellation,
and unhandled-rejection tests as canaries.
This commit is contained in:
@@ -28,7 +28,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
|
|||||||
|
|
||||||
**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers.
|
**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers.
|
||||||
|
|
||||||
**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.
|
**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 are realm-built throughout, so the script never holds a live host-prototype object: `args` and `agent()` results via the context's own `JSON.parse`, combinator result arrays via its `Array.from`, hook promises via its `Promise.resolve`, and hook failures as realm-built clones (name/code/message/fatal — the combinators recognize fatal clones structurally). 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/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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce
|
|||||||
|
|
||||||
## Realm discipline
|
## Realm discipline
|
||||||
|
|
||||||
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `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 through the context's own `JSON.parse`, and the arrays `parallel`/`pipeline` resolve to are realm-built, so the script never holds an object whose prototype chain reaches host intrinsics.
|
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm are realm-built throughout, so the script never holds an object whose prototype chain reaches host intrinsics: `args` and `agent()` results are rebuilt through the context's own `JSON.parse`, combinator result arrays through its `Array.from`, hook promises through its `Promise.resolve`, and a hook failure (rejection or synchronous `phase`/`log` throw) crosses as a realm-built clone carrying name/code/message/fatal — the combinators recognize fatal clones structurally, so the fatal-vs-null discipline survives the boundary.
|
||||||
|
|
||||||
## Limits, cancellation, disposal
|
## Limits, cancellation, disposal
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,22 @@ function ownDataProperty(value: object, key: string): unknown {
|
|||||||
return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
|
return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `error` is a FATAL realm-built `WorkflowError` clone — the shape the
|
||||||
|
* engine's hooks reject with (host errors are translated at the realm boundary
|
||||||
|
* so the script never holds host prototypes), duck-checked because a realm
|
||||||
|
* object cannot be an `instanceof` the host class. Proxy-guarded and
|
||||||
|
* descriptor-read, so a forged object cannot run code here; a script forging
|
||||||
|
* the shape only kills its own run (self-sabotage). Combinators use this to
|
||||||
|
* decide re-throw vs per-item `null`.
|
||||||
|
* @param error - the value a combinator caught from a realm thunk/stage.
|
||||||
|
* @returns `true` when the error must propagate and kill the script.
|
||||||
|
*/
|
||||||
|
export function isFatalWorkflowErrorClone(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null || types.isProxy(error)) return false
|
||||||
|
return ownDataProperty(error, 'name') === 'WorkflowError' && ownDataProperty(error, 'fatal') === true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether an object's prototype chain is data-shaped: `null`, or a prototype
|
* 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
|
* whose own prototype is `null` (the realm's `Object.prototype` — which we
|
||||||
|
|||||||
@@ -9,17 +9,22 @@
|
|||||||
* descriptor walks; values ENTERING the realm from the host (`args`, agent()
|
* descriptor walks; values ENTERING the realm from the host (`args`, agent()
|
||||||
* results) are rebuilt INSIDE the realm through the context's own
|
* results) are rebuilt INSIDE the realm through the context's own
|
||||||
* `JSON.parse`, so the script never holds an object whose prototype chain
|
* `JSON.parse`, so the script never holds an object whose prototype chain
|
||||||
* reaches host intrinsics. The arrays `parallel`/`pipeline` resolve to are
|
* reaches host intrinsics. The same rule covers every other value a hook
|
||||||
* realm-built for the same reason (their ELEMENTS are realm values already —
|
* hands the script: the promises `agent`/`parallel`/`pipeline` return are
|
||||||
* only the container needs rebuilding). Realm functions (pipeline stages,
|
* realm promises (the realm's own `Promise.resolve` over the host promise),
|
||||||
* parallel thunks) are called, not materialized — their values stay
|
* the arrays the combinators resolve to are realm-built (their ELEMENTS are
|
||||||
* realm-side.
|
* realm values already — only the container needs rebuilding), and a hook
|
||||||
|
* failure — rejection or synchronous `phase`/`log` throw — crosses as a
|
||||||
|
* realm-built clone carrying name/code/message/fatal. Realm functions
|
||||||
|
* (pipeline stages, parallel thunks) are called, not materialized — their
|
||||||
|
* values stay realm-side.
|
||||||
*
|
*
|
||||||
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
||||||
* unsupported options/schemas, tripped caps, seam start failures,
|
* unsupported options/schemas, tripped caps, seam start failures,
|
||||||
* cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item
|
* cancellation) ALWAYS propagate through `parallel`/`pipeline` — they cross
|
||||||
* `null` is reserved for child-run failures and ordinary in-stage script
|
* the realm boundary as fatal clones, recognized structurally — and the
|
||||||
* errors. Every hook-returned promise gets a no-op rejection consumer
|
* per-item `null` is reserved for child-run failures and ordinary in-stage
|
||||||
|
* script errors. Every hook-returned promise gets a no-op rejection consumer
|
||||||
* attached, so a script that drops a promise (fires an `agent()` without
|
* attached, so a script that drops a promise (fires an `agent()` without
|
||||||
* awaiting it) cannot surface an unhandled rejection when cancellation
|
* awaiting it) cannot surface an unhandled rejection when cancellation
|
||||||
* rejects it — the app boot layer exits the process on unhandled rejections.
|
* rejects it — the app boot layer exits the process on unhandled rejections.
|
||||||
@@ -34,14 +39,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
|||||||
import type {} from '@deepseek-ai/dsh-subagent'
|
import type {} from '@deepseek-ai/dsh-subagent'
|
||||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
||||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow'
|
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||||
import type {
|
import type {
|
||||||
WorkflowAgentEndInfo,
|
WorkflowAgentEndInfo,
|
||||||
WorkflowAgentInfo,
|
WorkflowAgentInfo,
|
||||||
WorkflowMeta,
|
WorkflowMeta,
|
||||||
WorkflowResult,
|
WorkflowResult,
|
||||||
} from '@deepseek-ai/dsh-workflow'
|
} from '@deepseek-ai/dsh-workflow'
|
||||||
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts'
|
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, isFatalWorkflowErrorClone, REALM_THROWN_RENDERER_SOURCE } from './realm.ts'
|
||||||
|
|
||||||
/** The per-run knobs the engine resolves from its Config. */
|
/** The per-run knobs the engine resolves from its Config. */
|
||||||
export interface ExecutionLimits {
|
export interface ExecutionLimits {
|
||||||
@@ -121,6 +126,8 @@ export class WorkflowExecution {
|
|||||||
private readonly context: vm.Context
|
private readonly context: vm.Context
|
||||||
private readonly realmJsonParse: (text: string) => unknown
|
private readonly realmJsonParse: (text: string) => unknown
|
||||||
private readonly realmArrayFrom: (items: unknown[]) => unknown[]
|
private readonly realmArrayFrom: (items: unknown[]) => unknown[]
|
||||||
|
private readonly realmPromiseResolve: (value: unknown) => Promise<unknown>
|
||||||
|
private readonly realmErrorClone: (name: string, code: string | undefined, message: string, fatal: boolean) => unknown
|
||||||
private readonly compiled: vm.Script
|
private readonly compiled: vm.Script
|
||||||
/** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */
|
/** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */
|
||||||
private readonly inFlightAgents = new Set<Promise<unknown>>()
|
private readonly inFlightAgents = new Set<Promise<unknown>>()
|
||||||
@@ -159,16 +166,38 @@ export class WorkflowExecution {
|
|||||||
// The realm's own JSON.parse — the host→realm rebuild channel.
|
// The realm's own JSON.parse — the host→realm rebuild channel.
|
||||||
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown }
|
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown }
|
||||||
this.realmJsonParse = (text: string) => realmJson.parse(text)
|
this.realmJsonParse = (text: string) => realmJson.parse(text)
|
||||||
// The realm's own Array.from, bound NOW so a script reassigning its
|
// The realm's own Array.from / Promise.resolve / an error factory, bound
|
||||||
// globals later cannot swap it: combinator results must be realm arrays.
|
// NOW so a script reassigning its globals later cannot swap them:
|
||||||
|
// combinator results must be realm arrays, hook promises realm promises,
|
||||||
|
// and hook failures realm-built clones.
|
||||||
this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[]
|
this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[]
|
||||||
|
this.realmPromiseResolve = vm.runInContext('Promise.resolve.bind(Promise)', this.context) as (value: unknown) => Promise<unknown>
|
||||||
|
this.realmErrorClone = vm.runInContext(`(name, code, message, fatal) => {
|
||||||
|
const error = new Error(message)
|
||||||
|
error.name = name
|
||||||
|
if (code !== undefined) error.code = code
|
||||||
|
error.fatal = fatal
|
||||||
|
return error
|
||||||
|
}`, this.context) as (name: string, code: string | undefined, message: string, fatal: boolean) => unknown
|
||||||
|
|
||||||
const globals: Record<string, unknown> = {
|
const globals: Record<string, unknown> = {
|
||||||
agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))),
|
agent: (prompt: unknown, opts?: unknown) => this.realmFacing(this.track(this.agent(prompt, opts))),
|
||||||
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
|
parallel: (thunks: unknown) => this.realmFacing(this.parallel(thunks)),
|
||||||
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
|
pipeline: (items: unknown, ...stages: unknown[]) => this.realmFacing(this.pipeline(items, stages)),
|
||||||
phase: (title: unknown) => { this.phase(title) },
|
phase: (title: unknown) => {
|
||||||
log: (message: unknown) => { this.log(message) },
|
try {
|
||||||
|
this.phase(title)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw this.toRealmError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
log: (message: unknown) => {
|
||||||
|
try {
|
||||||
|
this.log(message)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw this.toRealmError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
args: this.toRealm(args),
|
args: this.toRealm(args),
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(globals)) {
|
for (const [key, value] of Object.entries(globals)) {
|
||||||
@@ -228,8 +257,12 @@ export class WorkflowExecution {
|
|||||||
const value = raw === undefined ? null : this.materializeResult(raw)
|
const value = raw === undefined ? null : this.materializeResult(raw)
|
||||||
return { value, stopReason: 'completed', agentsStarted: this.started }
|
return { value, stopReason: 'completed', agentsStarted: this.started }
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
|
// Any failure after cancel() reports `cancelled` with the canonical
|
||||||
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
|
// reason — the reject path mirrors the resolve path's post-settle
|
||||||
|
// check, and a hook CANCELLED failure crosses the realm boundary as a
|
||||||
|
// clone that deliberately fails the host `instanceof`.
|
||||||
|
if (this.isCancelled()) {
|
||||||
|
return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
|
||||||
}
|
}
|
||||||
// Ordinary script failures arrive pre-rendered by the realm-side catch
|
// Ordinary script failures arrive pre-rendered by the realm-side catch
|
||||||
// (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError)
|
// (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError)
|
||||||
@@ -258,6 +291,37 @@ export class WorkflowExecution {
|
|||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand a hook's host promise to the script as a REALM promise (the realm's
|
||||||
|
* own `Promise.resolve` assimilates it) whose failure reason is a
|
||||||
|
* realm-built clone — the script must never hold host prototypes, and both
|
||||||
|
* the promise object and a caught rejection would otherwise expose them
|
||||||
|
* (module doc). The realm promise gets the same no-op rejection consumer as
|
||||||
|
* {@link contain}, since the script may drop it; the intermediate host
|
||||||
|
* promises are handled by the assimilation chain itself.
|
||||||
|
*/
|
||||||
|
private realmFacing(hostPromise: Promise<unknown>): Promise<unknown> {
|
||||||
|
const translated = hostPromise.catch((error: unknown) => {
|
||||||
|
throw this.toRealmError(error)
|
||||||
|
})
|
||||||
|
const realmPromise = this.realmPromiseResolve(translated)
|
||||||
|
realmPromise.catch(() => { /* consumed: a script-dropped realm promise must not surface an unhandled rejection (see contain) */ })
|
||||||
|
return realmPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild a host failure as a realm-built error clone: a `WorkflowError`
|
||||||
|
* keeps its name/code/message/fatal (the combinators recognize the shape
|
||||||
|
* via {@link isFatalWorkflowErrorClone}); anything else becomes a generic
|
||||||
|
* realm `Error` carrying its {@link describeThrown} rendering.
|
||||||
|
*/
|
||||||
|
private toRealmError(error: unknown): unknown {
|
||||||
|
if (error instanceof WorkflowError) {
|
||||||
|
return this.realmErrorClone('WorkflowError', error.code, error.message, error.fatal)
|
||||||
|
}
|
||||||
|
return this.realmErrorClone('Error', undefined, describeThrown(error), false)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register one `agent()` call promise for {@link quiesce} tracking; the
|
* Register one `agent()` call promise for {@link quiesce} tracking; the
|
||||||
* entry drops when the call fully settles (which is AFTER its child's
|
* entry drops when the call fully settles (which is AFTER its child's
|
||||||
@@ -472,7 +536,10 @@ export class WorkflowExecution {
|
|||||||
try {
|
try {
|
||||||
return await thunk()
|
return await thunk()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isFatalWorkflowError(error)) throw error
|
// Hooks translate host errors at the realm boundary, so a fatal error
|
||||||
|
// reaches a thunk catch only as a realm clone (a script forging the
|
||||||
|
// shape merely kills its own run).
|
||||||
|
if (isFatalWorkflowErrorClone(error)) throw error
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -505,8 +572,9 @@ export class WorkflowExecution {
|
|||||||
return value
|
return value
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// An ordinary stage throw drops the ITEM to null and skips its
|
// An ordinary stage throw drops the ITEM to null and skips its
|
||||||
// remaining stages; a fatal error kills the whole script.
|
// remaining stages; a fatal error (a realm clone — see parallel())
|
||||||
if (isFatalWorkflowError(error)) throw error
|
// kills the whole script.
|
||||||
|
if (isFatalWorkflowErrorClone(error)) throw error
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -288,9 +288,23 @@ describe('dsh-workflow-vm', () => {
|
|||||||
() => { throw new Error('boom') },
|
() => { throw new Error('boom') },
|
||||||
() => agent('fine'),
|
() => agent('fine'),
|
||||||
() => 'plain value',
|
() => 'plain value',
|
||||||
|
() => { throw 'string throw' },
|
||||||
|
() => { throw new Proxy({ name: 'WorkflowError', fatal: true }, {}) },
|
||||||
|
() => { throw { name: 'WorkflowError', fatal: 'forged-but-not-true' } },
|
||||||
])
|
])
|
||||||
`))
|
`))
|
||||||
expect(result.value).toEqual([null, 'stub reply', 'plain value'])
|
// The last three probe the fatal-clone recognition: a non-object, a
|
||||||
|
// proxy (never inspected), and a shape miss are all ordinary nulls.
|
||||||
|
expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null, null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a script forging a fatal clone kills only its own run (self-sabotage, not a bypass)', async () => {
|
||||||
|
const { ctx, parent } = await setup()
|
||||||
|
const result = await run(ctx, parent, script(`
|
||||||
|
return await parallel([() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }])
|
||||||
|
`))
|
||||||
|
expect(result.stopReason).toBe('error')
|
||||||
|
expect(result.error).toContain('forged fatal')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => {
|
it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => {
|
||||||
@@ -433,6 +447,86 @@ describe('dsh-workflow-vm', () => {
|
|||||||
expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined')
|
expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hook promises are REALM promises: instanceof holds in-script, host Promise.prototype stays unreachable', async () => {
|
||||||
|
const { ctx, parent } = await setup()
|
||||||
|
const result = await run(ctx, parent, script(`
|
||||||
|
const p = agent('x')
|
||||||
|
const par = parallel([() => 'v'])
|
||||||
|
const pipe = pipeline([1], (n) => n)
|
||||||
|
Object.getPrototypeOf(p).wfLeakProbe = 'realm-only'
|
||||||
|
return {
|
||||||
|
agentIsRealmPromise: p instanceof Promise,
|
||||||
|
parallelIsRealmPromise: par instanceof Promise,
|
||||||
|
pipelineIsRealmPromise: pipe instanceof Promise,
|
||||||
|
value: await p,
|
||||||
|
}
|
||||||
|
`))
|
||||||
|
expect(result.stopReason).toBe('completed')
|
||||||
|
expect(result.value).toEqual({
|
||||||
|
agentIsRealmPromise: true,
|
||||||
|
parallelIsRealmPromise: true,
|
||||||
|
pipelineIsRealmPromise: true,
|
||||||
|
value: 'stub reply',
|
||||||
|
})
|
||||||
|
expect((Promise.prototype as unknown as Record<string, unknown>).wfLeakProbe).toBeUndefined()
|
||||||
|
delete (Promise.prototype as unknown as Record<string, unknown>).wfLeakProbe
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hook failures cross the boundary as realm-built WorkflowError clones', async () => {
|
||||||
|
const { ctx, parent } = await setup()
|
||||||
|
const result = await run(ctx, parent, script(`
|
||||||
|
try {
|
||||||
|
await agent('p', { bogus: true })
|
||||||
|
return 'unreachable'
|
||||||
|
} catch (e) {
|
||||||
|
Object.getPrototypeOf(Object.getPrototypeOf(e)).wfErrLeakProbe = 'realm-only'
|
||||||
|
return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message }
|
||||||
|
}
|
||||||
|
`))
|
||||||
|
expect(result.stopReason).toBe('completed')
|
||||||
|
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true })
|
||||||
|
expect((result.value as { message: string }).message).toContain('"bogus" is not recognized')
|
||||||
|
// The script mutated its error's prototype CHAIN — host intrinsics untouched.
|
||||||
|
expect((Object.prototype as unknown as Record<string, unknown>).wfErrLeakProbe).toBeUndefined()
|
||||||
|
expect((Error.prototype as unknown as Record<string, unknown>).wfErrLeakProbe).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a non-WorkflowError host failure (a rejecting provider result) crosses as a generic realm clone', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SubagentService)
|
||||||
|
const provider: SubagentProvider = {
|
||||||
|
name: 'rejecting',
|
||||||
|
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true },
|
||||||
|
start: () => ({
|
||||||
|
id: AgentId('reject-child'),
|
||||||
|
result: Promise.reject(new Error('backend exploded')),
|
||||||
|
cancel: () => { /* nothing in flight */ },
|
||||||
|
dispose: () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
ctx.subagents.registerProvider(provider)
|
||||||
|
await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' })
|
||||||
|
const result = await run(ctx, fakeParent(), script(`
|
||||||
|
try { await agent('p'); return 'unreachable' } catch (e) { return { isRealmError: e instanceof Error, name: e.name, message: e.message } }
|
||||||
|
`))
|
||||||
|
expect(result.value).toMatchObject({ isRealmError: true, name: 'Error' })
|
||||||
|
expect((result.value as { message: string }).message).toContain('backend exploded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('phase()/log() synchronous throws cross as realm clones too', async () => {
|
||||||
|
const { ctx, parent } = await setup()
|
||||||
|
const result = await run(ctx, parent, script(`
|
||||||
|
try { phase(3) } catch (e) {
|
||||||
|
if (!(e instanceof Error) || e.name !== 'WorkflowError') throw e
|
||||||
|
}
|
||||||
|
try { log(3) } catch (e) {
|
||||||
|
return { isRealmError: e instanceof Error, name: e.name, message: e.message }
|
||||||
|
}
|
||||||
|
`))
|
||||||
|
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError' })
|
||||||
|
expect((result.value as { message: string }).message).toContain('log() requires')
|
||||||
|
})
|
||||||
|
|
||||||
it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => {
|
it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => {
|
||||||
const { ctx, parent } = await setup()
|
const { ctx, parent } = await setup()
|
||||||
const result = await run(ctx, parent, script(`
|
const result = await run(ctx, parent, script(`
|
||||||
|
|||||||
Reference in New Issue
Block a user