workflow: simplify to the trust premise; settle result on cancellation

Two review responses that belong together — the same review argued the
engine was defending the wrong threat while a benign-input bug wedged
the product.

1) Drop hostile-value containment; state the trust premise.

Scripts are model-written — the same trust level as the model's bash
access — yet successive pre-push review rounds had ratcheted in defenses
that only matter against an adversarial author: trap-free proxy
rejection, accessor-never-invoked descriptor walks, realm-side
pre-rendering of thrown values, realm-built promises/arrays/error clones
with structural fatal recognition. That same author keeps a documented,
accepted, unkillable event-loop spin, so containing its error VALUES is
cost without a threat model — and the planned hardened engine
(worker/isolated-vm) gets value isolation by serialization and deletes
all of this machinery anyway.

What stays, because benign scripts hit it constantly: result never
rejects; dropped hook promises cannot become unhandled rejections; the
value boundary rejects LOUD everything JSON cannot carry (now a plain
recursive walk — getters are read ordinarily and their result is what
crosses; a throwing read fails loud); a "__proto__" key still copies as
a data property; the fatal-vs-null combinator discipline (now host
instanceof — unforgeable from the realm and simpler than clone-shape
recognition). What changes for scripts (documented in the engine
README): hooks hand back host values and host errors — in-script
`instanceof Error` on a hook failure is false (branch on e.name/e.code)
— and args are host-cloned once so a script cannot mutate the caller's
object. realm.ts drops 289 → 173 lines; the hostile-value test tables go
with it. The premise now leads the engine module doc, the README, and
the RFC's engine section, with the removed machinery recorded under
What was rejected.

2) result settles within the dispose grace of a cancellation.

Review finding (verified through the real registry + tool + engine): a
script parked on a promise no hook owns — `await new Promise(() => {})`,
`await Promise.race([])`, a returned never-settling thenable — could not
be settled by cancel(): hooks reject and children abort, but nothing
touches a promise the engine does not own, so `result` stayed pending
FOREVER (the previous cut even pinned that as intended). The tool awaits
run.result BEFORE its disposing finally, the registry awaits the tool,
the loop awaits the registry — one such script wedged the whole agent
turn past any abort, unrecoverable in-process; the mock engine in the
tool's abort test settles result on cancel, which is exactly the
behavior the real engine lacked, so no existing test could see it.

The seam contract now says it out loud: once a run is cancelled, result
SETTLES within the implementation's bounded grace even if the script
never does. The vm engine arms an abandon channel in cancel(); drive()
races the script against it, force-settling 'cancelled' at the grace
(the abandoned settlement stays contained; a post-slice synchronous spin
remains the documented limitation). dispose()'s outer race now exists
for child quiescence only, and `workflow/end` again fires exactly once
per started run. The old 'result stays pending' pin is FLIPPED to the
new contract (the pinned behavior was the bug); new regressions cover
cancel-then-settle on a parked script, a never-settling returned
thenable, and the full composition through the REAL registry + tool +
vm engine (tool-workflow gains workflow-vm/subagent devDeps for it).
agentsStarted JSDoc clarified while touching the vocabulary (accepted
calls, including ones still queued at cancellation).
This commit is contained in:
Tianyi Cui
2026-07-06 00:48:49 +08:00
parent 7234d41b91
commit 2accf85714
16 changed files with 352 additions and 609 deletions

View File

@@ -47,7 +47,7 @@ interface WorkflowResult {
## A live run: `WorkflowRun` ## A live run: `WorkflowRun`
The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result. `dispose()` cancels, waits a bounded grace for the script to settle AND its children to finish disposing, then abandons whatever is left (the engine documents the abandonment semantics); it never hangs on a stuck script. The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine abandons the script and reports `cancelled`), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence (the engine documents what abandonment leaves behind); it never hangs on a stuck script.
```ts type-equiv ```ts type-equiv
interface WorkflowRun { interface WorkflowRun {

View File

@@ -24,13 +24,13 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
### The engine (dsh-workflow-vm): in-process node:vm ### The engine (dsh-workflow-vm): in-process node:vm
**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. **Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) — host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses.
**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Under the trust premise, in-process is enough. Accepted, documented limitations: `start()` blocks the caller for the script's initial synchronous slice (bounded by the vm timeout); that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work — or script code the host runs while rendering a thrown value) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons.
**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 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. **Value boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud). Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is host-`structuredClone`d once so a script cannot mutate the caller's object. Hook failures are host `WorkflowError`s: the combinators recognize fatality by host `instanceof` (unforgeable from the realm), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total host-side renderer (stack → message → `String()`, fixed label if rendering throws), 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.
### The consumer (dsh-tool-workflow) ### The consumer (dsh-tool-workflow)
@@ -42,6 +42,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
## What was rejected ## What was rejected
- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts, who retains an accepted unkillable event-loop spin regardless. Removed in favor of the plain boundary above; the hardened engine deletes such machinery anyway (serialization by construction).
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
- **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. - **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in.

View File

@@ -35,9 +35,11 @@
"@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow-vm": "workspace:^",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
} }
} }

View File

@@ -9,6 +9,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
import * as toolWorkflow from '../src/index.ts' import * as toolWorkflow from '../src/index.ts'
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
@@ -221,4 +223,33 @@ describe('dsh-tool-workflow', () => {
expect(unwrapped).toBe(toolWorkflow) expect(unwrapped).toBe(toolWorkflow)
expect(typeof unwrapped.apply).toBe('function') expect(typeof unwrapped.apply).toBe('function')
}) })
describe('composition with the REAL vm engine (the mock above must stay honest)', () => {
it('an abort releases the tool even when the script parks on a promise no hook owns', async () => {
// Regression for the review-found turn wedge: the tool awaits
// run.result BEFORE its disposing finally, the registry and the loop
// await the tool — so if cancellation could not settle result (a script
// parked on `await new Promise(() => {})`), an aborted turn stayed
// wedged forever. The seam now guarantees result settles within the
// grace of cancel(); this drives that guarantee through the real
// registry + real tool + real engine.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
await ctx.plugin(VmWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
const controller = new AbortController()
const pending = execute(ctx, {
script: "export const meta = { name: 'stuck', description: 'parks forever' }\nawait new Promise(() => {})\nreturn 1",
}, { agent: parent, signal: controller.signal })
// Give the run a beat to start (past its synchronous slice), then abort.
await new Promise(resolve => setTimeout(resolve, 20))
controller.abort('user abort')
const result = await pending
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('cancelled')
})
})
}) })

View File

@@ -2,21 +2,25 @@
The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md). The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md).
## Trust premise
Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here.
## The script contract it executes ## The script contract it executes
- **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers.
- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline).
- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context. - **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context.
## Realm discipline ## The value boundary
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. Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is `structuredClone`d once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is a HOST error, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by host `instanceof`, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
## Limits, cancellation, disposal ## Limits, cancellation, disposal
Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile `stack` getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter) — `result` cannot reject. Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks, and a script that STILL has not settled `disposeGraceMs` after the cancel (parked on a promise no hook owns, like `await new Promise(() => {})`) is ABANDONED with `result` force-settling `cancelled` — a consumer awaiting `result` is never wedged past a cancellation. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection; thrown script values are rendered by a total host-side renderer (stack, then message, then `String()`, with a fixed label if rendering itself throws) — `result` cannot reject.
**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the realm-boundary guard applies to the resolution. **Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): `start()` runs the script's initial synchronous slice inline, so the caller blocks until the first await or the vm `timeout`; that `timeout` covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution, or script code the host runs while rendering a thrown value) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the value-boundary guard applies to the resolution.
## Config ## Config

View File

@@ -4,25 +4,33 @@
* body in a fresh in-process vm context with the workflow hooks injected, and * body in a fresh in-process vm context with the workflow hooks injected, and
* fans `agent()` calls out to `ctx.subagents`. * fans `agent()` calls out to `ctx.subagents`.
* *
* Engine limitations, documented as the accepted cost of the in-process * TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the
* mechanism (the interface/implementation seam exists precisely so a * model's existing bash access — so this engine defends against BUGGY
* worker-thread or isolated-vm engine can swap in if these ever matter): * scripts, never hostile ones. vm is NOT a security boundary and no attempt
* is made to contain adversarial values (see ./realm.ts); genuine sandboxing
* is an engine swap behind the seam (worker-thread/isolated-vm), not
* incremental host-side defenses here.
* *
* - vm is NOT a security boundary. Scripts are model-written — the same trust * Engine limitations, documented as the accepted cost of the in-process
* level as the model's bash access — and the realm-boundary materialization * mechanism:
* is correctness containment, not a sandbox. *
* - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script; * - `start()` runs the script's initial SYNCHRONOUS slice inline, so the
* realm code that runs past that slice — an await continuation, a * CALLER blocks on the host event loop until the script's first await (or
* thenable's `then` invoked by promise resolution (including one the script * the vm `timeout` kills the slice); the meta-literal evaluation has its
* RETURNS: a returned thenable resolves per JavaScript semantics before * own timeout budget on the same call.
* materialization, which is what makes an un-awaited `return agent('x')` * - The vm `timeout` covers only that initial slice; realm code running past
* work) — is beyond the timeout, so a pathological synchronous spin there * it — an await continuation, a thenable's `then` invoked by promise
* cannot be killed in-process. `dispose()` waits a bounded grace for the * resolution (including one the script RETURNS: a returned thenable
* script to settle AND its children (stray `agent()` calls included) to * resolves per JavaScript semantics before materialization, which is what
* finish disposing, then ABANDONS whatever is left: pending hook promises * makes an un-awaited `return agent('x')` work) — is beyond the timeout, so
* are already rejected and the script's settlement is contained (no * a synchronous spin there cannot be killed in-process, and neither can
* unhandled rejection), but an abandoned synchronous spin would still * script code the host invokes while rendering a failure (a getter on a
* occupy the event loop. * thrown value). `dispose()` waits a bounded grace for the script to settle
* AND its children (stray `agent()` calls included) to finish disposing,
* then ABANDONS whatever is left: pending hook promises are already
* rejected and the script's settlement is contained (no unhandled
* rejection), but an abandoned synchronous spin would still occupy the
* event loop.
* *
* Plugin export shape: a default-exported {@link WorkflowService} subclass * Plugin export shape: a default-exported {@link WorkflowService} subclass
* (the class-based service form, like `dsh-bash-local`). * (the class-based service form, like `dsh-bash-local`).
@@ -55,7 +63,11 @@ export interface Config {
maxItemsPerCall?: number maxItemsPerCall?: number
/** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */ /** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */
syncTimeoutMs?: number syncTimeoutMs?: number
/** How long `dispose()` waits for a cancelled script to settle before abandoning it (default 5000 ms). */ /**
* How long after a cancellation an unsettled script may keep running before
* it is abandoned and `result` force-settles `cancelled` (default 5000 ms);
* also bounds `dispose()`.
*/
disposeGraceMs?: number disposeGraceMs?: number
} }
@@ -110,6 +122,7 @@ export class VmWorkflowEngine extends WorkflowService {
maxTotalAgents: this.config.maxTotalAgents, maxTotalAgents: this.config.maxTotalAgents,
maxItemsPerCall: this.config.maxItemsPerCall, maxItemsPerCall: this.config.maxItemsPerCall,
syncTimeoutMs: this.config.syncTimeoutMs, syncTimeoutMs: this.config.syncTimeoutMs,
disposeGraceMs: this.config.disposeGraceMs,
} }
const execution = new WorkflowExecution( const execution = new WorkflowExecution(
this.ctx, this.ctx,
@@ -149,9 +162,11 @@ export class VmWorkflowEngine extends WorkflowService {
}, },
dispose: (): Promise<void> => { dispose: (): Promise<void> => {
// Idempotent: cancel, then wait min(settle + child quiescence, grace). // Idempotent: cancel, then wait min(settle + child quiescence, grace).
// `result` and `quiesce()` never reject, so the race needs no // The cancel itself bounds `result` (the execution abandons a script
// rejection handling; a script or child still unsettled past the grace // still unsettled `disposeGraceMs` later), so this outer race exists
// is abandoned per the module contract. // for CHILD quiescence: a slow-disposing child must not hold dispose
// past the grace. `result` and `quiesce()` never reject, so the race
// needs no rejection handling.
disposed ??= (async () => { disposed ??= (async () => {
execution.cancel('workflow disposed') execution.cancel('workflow disposed')
await Promise.race([ await Promise.race([

View File

@@ -20,7 +20,7 @@
import * as vm from 'node:vm' import * as vm from 'node:vm'
import { WorkflowError } from '@deepseek-ai/dsh-workflow' import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
/** The result of {@link extractMeta}: the validated meta and the runnable body. */ /** The result of {@link extractMeta}: the validated meta and the runnable body. */
export interface ExtractedScript { export interface ExtractedScript {
@@ -171,18 +171,11 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr
// An EMPTY context: any non-literal reference (a variable, a call) throws // 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 // 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 // IIFE can still run, which is why the timeout and the materialization
// below are part of the same boundary. A thrown value is pre-rendered by // below are part of the same boundary.
// the realm-side catch INSIDE the timed window, so a hostile evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
// 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) { } catch (error: unknown) {
throw new WorkflowError( throw new WorkflowError(
`meta block failed to evaluate as a pure literal: ${thrownRendering(error) ?? describeThrown(error)}`, `meta block failed to evaluate as a pure literal: ${renderThrown(error)}`,
'META_INVALID', 'META_INVALID',
{ cause: error }, { cause: error },
) )

View File

@@ -1,48 +1,34 @@
/** /**
* Realm-boundary materialization for the vm engine. * The vm engine's value boundary: copy script-realm values into plain host
* JSON data — loud about everything JSON cannot carry — and render thrown
* script values to failure text.
* *
* Values produced INSIDE the script realm (the meta literal, hook arguments, * TRUST PREMISE (everything in this module hangs on it): workflow scripts are
* the script's return value) must become plain host-realm JSON data before the * MODEL-WRITTEN, the same trust level as the model's existing bash access, so
* host touches them. The repo's `isJsonValue` guard cannot run first: it is * this boundary guards against BUGGY scripts, not hostile ones. It rejects
* prototype-strict (any cross-realm object fails it) and it INVOKES getters * loud what JSON would silently mangle — functions, symbols, bigints,
* (letting realm code run outside the vm's timed window). So this module walks * non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic
* own-property DESCRIPTORS — never invoking accessors — and copies data into * prototypes — because accepted-then-ignored is this repo's banned failure
* host containers, rejecting loud everything JSON cannot carry: * mode. It does NOT defend against adversarial values: the walk reads
* accessor properties, non-plain prototypes, functions, symbols (keys or * properties ordinarily (a getter runs, and whatever it returns is what
* values), bigints, non-finite numbers, `undefined` values, cycles, sparse * crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly,
* arrays, arrays with non-index own properties, and proxies. Proxies are * and a proxy is walked through its traps. A hostile script gains nothing
* rejected via the trap-free native `util.types.isProxy` check BEFORE any * worth defending here — it can already occupy the event loop forever with a
* other inspection — a descriptor walk over a proxy would otherwise run its * synchronous spin past the first await (the engine's documented, accepted
* realm-side traps (`ownKeys`, `getOwnPropertyDescriptor`, `getPrototypeOf`) * limitation) — so host-side hostile-value containment would be cost without
* on the host stack, outside the vm's timed window, and a throwing trap would * a threat model; genuine hardening is an ENGINE SWAP (worker/isolated-vm,
* escape as a raw realm error instead of a {@link MaterializeError}. The same * where the boundary is serialization by construction), not incremental
* check guards the PROTOTYPE position (an object whose prototype is a proxy). * defenses here.
* *
* Host objects are built with `Object.defineProperty` into a fresh `{}` — * The host→realm direction needs no machinery at all: hooks hand the script
* never plain `target[key] =` assignment, which a `"__proto__"` key would turn * plain host values, host prototypes included — the script is trusted. One
* into prototype mutation instead of a data property. * consequence is documented in the engine README: an error thrown by a hook
* * is a HOST error, so an in-script `instanceof Error` check is false; read
* The host→realm direction deliberately does NOT live here: a host object * `name`/`code`/`message` instead.
* handed into the realm would expose host intrinsics through its prototype
* chain, so the engine rebuilds inbound values INSIDE the realm via the
* context's own `JSON.parse` (see the runtime).
*
* {@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 * @module @deepseek-ai/dsh-workflow-vm/realm
*/ */
import { types } from 'node:util'
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */ /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
export class MaterializeError extends Error { export class MaterializeError extends Error {
constructor(public readonly path: string, public readonly reason: string) { constructor(public readonly path: string, public readonly reason: string) {
@@ -52,154 +38,63 @@ export class MaterializeError extends Error {
} }
/** /**
* Realm-SOURCE text (an arrow-function expression) the engine compiles into * Render a thrown value to failure text without ever throwing: prefer the
* its script wrappers: `throw (RENDERER)(e)` inside a catch around the whole * `stack` (host or realm — a realm error's `stack` is a plain string read),
* body/literal. It renders the thrown value to a string INSIDE the realm's * fall back to `message`, then `String()`. Reading those properties MAY run
* own execution window — a hostile `stack`/`message` accessor or `toString` * script code (a getter, `toString`) — accepted under the module's trust
* invoked here is subject to the vm sync-slice timeout like any other script * premise; if that code itself throws, a fixed label is returned instead.
* 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. * @param error - the thrown value, of any shape and any realm.
* @returns human-readable text for the failure report; prefers the stack. * @returns human-readable text for the failure report; prefers the stack.
*/ */
export function describeThrown(error: unknown): string { export function renderThrown(error: unknown): string {
switch (typeof error) { try {
case 'object': const stack = (error as { stack?: unknown } | null | undefined)?.stack
break if (typeof stack === 'string' && stack.length > 0) return stack
case 'function': const message = (error as { message?: unknown } | null | undefined)?.message
return '[thrown function]' if (typeof message === 'string' && message.length > 0) return message
default: return String(error)
// Primitives (string/number/boolean/bigint/symbol/undefined): String() } catch {
// cannot reach user code on these. // A throwing accessor/toString on the thrown value — rendering must be
return String(error) // total (drive()'s never-reject contract), so fall back to a fixed label.
return '[unrenderable thrown value]'
} }
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` 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
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. */
function ownDataProperty(value: object, key: string): unknown {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
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
* cannot compare by identity across realms). A `Date`/`Map`/class instance * cannot compare by identity across realms). A `Date`/`Map`/class instance
* has a longer chain and is rejected, as is a proxy sitting in the prototype * has a longer chain and is rejected.
* position (checked trap-free BEFORE its own prototype is dereferenced).
*/ */
function hasPlainPrototype(value: object): boolean { function hasPlainPrototype(value: object): boolean {
const proto: unknown = Object.getPrototypeOf(value) const proto: unknown = Object.getPrototypeOf(value)
if (proto === null) return true if (proto === null) return true
if (types.isProxy(proto)) return false
return Object.getPrototypeOf(proto) === null return Object.getPrototypeOf(proto) === null
} }
/** /**
* Copy `value` (typically from the vm realm) into plain host JSON data. * Copy `value` (typically from the vm realm) into plain host JSON data.
* Throws {@link MaterializeError} naming the offending path for anything JSON * Throws {@link MaterializeError} naming the offending path for anything JSON
* cannot carry losslessly. Accessors are detected via descriptors and NEVER * cannot carry losslessly. Properties are read ordinarily — a getter runs and
* invoked. `undefined` is accepted only at the ROOT (a script with no * its RESULT is materialized; a read that throws surfaces as a
* `return` value) — the caller decides what it means; an `undefined` nested * {@link MaterializeError} carrying the rendered failure. `undefined` is
* INSIDE a container is a violation. * accepted only at the ROOT (a script with no `return` value) — the caller
* decides what it means; an `undefined` nested INSIDE a container is a
* violation.
* @param value - the realm value to materialize. * @param value - the realm value to materialize.
* @param root - the path label for the root value (error messages). * @param root - the path label for the root value (error messages).
* @returns the host-realm copy (plain objects/arrays/scalars only). * @returns the host-realm copy (plain objects/arrays/scalars only).
*/ */
export function materializeFromRealm(value: unknown, root = 'value'): unknown { export function materializeFromRealm(value: unknown, root = 'value'): unknown {
if (value === undefined) return undefined if (value === undefined) return undefined
return materialize(value, root, new Set()) try {
return materialize(value, root, new Set())
} catch (error: unknown) {
if (error instanceof MaterializeError) throw error
// A property read ran script code that threw; total-ize it so callers can
// keep the narrow MaterializeError contract.
throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`)
}
} }
function materialize(value: unknown, path: string, seen: Set<object>): unknown { function materialize(value: unknown, path: string, seen: Set<object>): unknown {
@@ -214,20 +109,15 @@ function materialize(value: unknown, path: string, seen: Set<object>): unknown {
case 'bigint': case 'bigint':
throw new MaterializeError(path, 'bigints are not JSON data') throw new MaterializeError(path, 'bigints are not JSON data')
case 'function': case 'function':
throw new MaterializeError(path, 'functions cannot cross the workflow realm boundary') throw new MaterializeError(path, 'functions cannot cross the workflow value boundary')
case 'symbol': case 'symbol':
throw new MaterializeError(path, 'symbols cannot cross the workflow realm boundary') throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary')
case 'undefined': case 'undefined':
throw new MaterializeError(path, 'undefined is not JSON data') throw new MaterializeError(path, 'undefined is not JSON data')
case 'object': case 'object':
break break
} }
if (value === null) return null if (value === null) return null
// BEFORE anything else touches the object: every inspection below —
// Array.isArray aside — can trigger a proxy trap, running realm code on the
// host stack (module doc). isProxy is a native internal-slot check (no
// traps, catches revoked proxies, realm-agnostic).
if (types.isProxy(value)) throw new MaterializeError(path, 'proxies cannot cross the workflow realm boundary')
const objectValue: object = value const objectValue: object = value
if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data') if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
seen.add(objectValue) seen.add(objectValue)
@@ -242,10 +132,8 @@ function materialize(value: unknown, path: string, seen: Set<object>): unknown {
function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] { function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
const out: unknown[] = [] const out: unknown[] = []
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, index) if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
if (descriptor === undefined) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data') out.push(materialize(value[index], `${path}[${index}]`, seen))
if (!('value' in descriptor)) throw new MaterializeError(`${path}[${index}]`, 'accessor properties cannot cross the workflow realm boundary')
out.push(materialize(descriptor.value, `${path}[${index}]`, seen))
} }
// Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be // Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
// silently dropped by JSON — reject them instead. // silently dropped by JSON — reject them instead.
@@ -256,7 +144,7 @@ function materializeArray(value: unknown[], path: string, seen: Set<object>): un
} }
} }
if (Object.getOwnPropertySymbols(value).length > 0) { if (Object.getOwnPropertySymbols(value).length > 0) {
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
} }
return out return out
} }
@@ -266,20 +154,16 @@ function materializeObject(value: object, path: string, seen: Set<object>): Reco
throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
} }
if (Object.getOwnPropertySymbols(value).length > 0) { if (Object.getOwnPropertySymbols(value).length > 0) {
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
} }
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { // Object.keys = own enumerable string keys, matching JSON.stringify's
// Non-enumerable own props never reach JSON output — skip them, matching // property selection exactly (non-enumerable props never reach JSON output).
// JSON.stringify's contract exactly (documented in the module doc). for (const key of Object.keys(value)) {
if (!descriptor.enumerable) continue
if (!('value' in descriptor)) {
throw new MaterializeError(`${path}.${key}`, 'accessor properties cannot cross the workflow realm boundary')
}
// defineProperty, never assignment: a "__proto__" key must become an OWN // defineProperty, never assignment: a "__proto__" key must become an OWN
// data property of the copy, not a prototype mutation. // data property of the copy, not a prototype mutation.
Object.defineProperty(out, key, { Object.defineProperty(out, key, {
value: materialize(descriptor.value, `${path}.${key}`, seen), value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen),
enumerable: true, enumerable: true,
writable: true, writable: true,
configurable: true, configurable: true,

View File

@@ -4,30 +4,27 @@
* concurrency semaphore and caps, cancellation, and the drive loop that turns * concurrency semaphore and caps, cancellation, and the drive loop that turns
* a script settlement into a {@link WorkflowResult}. * a script settlement into a {@link WorkflowResult}.
* *
* Realm discipline (see also ./realm.ts): values ENTERING the host from the * Value boundary (the trust premise lives in ./realm.ts): values ENTERING the
* script (hook options, schemas, the return value) are materialized via * host from the script (hook options, schemas, the return value) are
* descriptor walks; values ENTERING the realm from the host (`args`, agent() * materialized by `materializeFromRealm` — a plain walk that rejects loud
* results) are rebuilt INSIDE the realm through the context's own * everything JSON cannot carry. Values ENTERING the realm (`args`, `agent()`
* `JSON.parse`, so the script never holds an object whose prototype chain * results, hook promises and their failures, combinator arrays) are handed
* reaches host intrinsics. The same rule covers every other value a hook * over DIRECTLY as host values: the script is model-written and trusted, so
* hands the script: the promises `agent`/`parallel`/`pipeline` return are * host prototypes are not a leak. `args` is host-side `structuredClone`d once
* realm promises (the realm's own `Promise.resolve` over the host promise), * at start so a script scribbling on it cannot mutate the caller's object —
* the arrays the combinators resolve to are realm-built (their ELEMENTS are * that is a benign-bug guard, not isolation. Realm functions (pipeline
* realm values already — only the container needs rebuilding), and a hook * stages, parallel thunks) are called, not materialized — their values stay
* failure — rejection or synchronous `phase`/`log` throw — crosses as a * realm-side until they cross through a hook or the final return.
* 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` — they cross * cancellation) ALWAYS propagate through `parallel`/`pipeline` — recognized
* the realm boundary as fatal clones, recognized structurally — and the * by host `instanceof`, which a script cannot forge — and the per-item `null`
* per-item `null` is reserved for child-run failures and ordinary in-stage * is reserved for child-run failures and ordinary in-stage script errors.
* script errors. Every hook-returned promise gets a no-op rejection consumer * Every hook-returned promise gets a no-op rejection consumer attached, so a
* attached, so a script that drops a promise (fires an `agent()` without * script that drops a promise (fires an `agent()` without awaiting it) cannot
* awaiting it) cannot surface an unhandled rejection when cancellation * surface an unhandled rejection when cancellation rejects it — the app boot
* rejects it — the app boot layer exits the process on unhandled rejections. * layer exits the process on unhandled rejections.
* *
* @module @deepseek-ai/dsh-workflow-vm/runtime * @module @deepseek-ai/dsh-workflow-vm/runtime
*/ */
@@ -39,14 +36,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 } from '@deepseek-ai/dsh-workflow' import { isFatalWorkflowError, 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, isFatalWorkflowErrorClone, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' import { materializeFromRealm, MaterializeError, renderThrown } 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 {
@@ -60,6 +57,8 @@ export interface ExecutionLimits {
maxItemsPerCall: number maxItemsPerCall: number
/** vm timeout for the script's initial synchronous slice. */ /** vm timeout for the script's initial synchronous slice. */
syncTimeoutMs: number syncTimeoutMs: number
/** How long after `cancel()` a still-unsettled script is abandoned (result force-settles `cancelled`). */
disposeGraceMs: number
} }
/** The engine-side observers the execution reports progress through. */ /** The engine-side observers the execution reports progress through. */
@@ -124,13 +123,24 @@ export class WorkflowExecution {
private readonly controller = new AbortController() private readonly controller = new AbortController()
private currentPhase: string | undefined private currentPhase: string | undefined
private readonly context: vm.Context private readonly context: vm.Context
private readonly realmJsonParse: (text: string) => 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>>()
/** Fires {@link abandoned}; assigned by the promise executor at field initialization. */
private declareAbandoned!: () => void
private abandonTimer: NodeJS.Timeout | undefined
/**
* Rejects `disposeGraceMs` after {@link cancel} if the script has not
* settled by then. `drive()` races the script against it, so `result`
* ALWAYS settles within the grace of a cancellation — even when the script
* is parked on a promise no hook owns (`await new Promise(() => {})`), which
* cancellation cannot reject. Without this, a consumer awaiting `result`
* before disposing (the tool's shape) would hang forever on such a script,
* wedging its caller past any abort.
*/
private readonly abandoned = new Promise<never>((_, reject) => {
this.declareAbandoned = () => { reject(new WorkflowError('workflow script abandoned after the cancellation grace', 'CANCELLED')) }
})
constructor( constructor(
private readonly ctx: Context, private readonly ctx: Context,
@@ -144,61 +154,34 @@ export class WorkflowExecution {
) { ) {
// Compile FIRST: a body syntax error must throw out of the constructor // 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 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 // lineOffset compensates for the wrapper line, so stack traces carry the
// script's own line numbers (the meta statement was blanked, not removed). // script's own line numbers (the meta statement was blanked, not removed).
try { try {
this.compiled = new vm.Script( this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
`(async () => { try {\n${body}\n} catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, filename: `workflow:${meta.name}`,
{ lineOffset: -1,
filename: `workflow:${meta.name}`, })
lineOffset: -1,
},
)
} catch (error: unknown) { } catch (error: unknown) {
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
} }
this.context = vm.createContext({}, { name: `workflow:${meta.name}` }) this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
vm.runInContext(DETERMINISM_PRELUDE, this.context) vm.runInContext(DETERMINISM_PRELUDE, this.context)
// The realm's own JSON.parse — the host→realm rebuild channel. // A run that settles without ever being abandoned leaves `abandoned`
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown } // permanently pending or rejecting into the void — consume it so a late
this.realmJsonParse = (text: string) => realmJson.parse(text) // grace timer cannot surface an unhandled rejection.
// The realm's own Array.from / Promise.resolve / an error factory, bound void this.contain(this.abandoned)
// 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.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.realmFacing(this.track(this.agent(prompt, opts))), agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))),
parallel: (thunks: unknown) => this.realmFacing(this.parallel(thunks)), parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
pipeline: (items: unknown, ...stages: unknown[]) => this.realmFacing(this.pipeline(items, stages)), pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
phase: (title: unknown) => { phase: (title: unknown) => { this.phase(title) },
try { log: (message: unknown) => { this.log(message) },
this.phase(title) // Host-side clone: a script scribbling on args must not mutate the
} catch (error: unknown) { // caller's object (a benign-bug guard; args is plain JSON by the seam
throw this.toRealmError(error) // contract, so structuredClone is total here and throws loud otherwise).
} args: args === undefined ? undefined : structuredClone(args),
},
log: (message: unknown) => {
try {
this.log(message)
} catch (error: unknown) {
throw this.toRealmError(error)
}
},
args: this.toRealm(args),
} }
for (const [key, value] of Object.entries(globals)) { for (const [key, value] of Object.entries(globals)) {
// Data properties on the contextified global; frozen shape not required — // Data properties on the contextified global; frozen shape not required —
@@ -226,7 +209,10 @@ export class WorkflowExecution {
/** /**
* Cancel the run: children abort (the shared signal), waiting `agent()` * Cancel the run: children abort (the shared signal), waiting `agent()`
* slots reject, and every future hook call throws `CANCELLED` — the script * slots reject, and every future hook call throws `CANCELLED` — the script
* dies at its next await. Idempotent; the first reason wins. * dies at its next await. A script that STILL has not settled after
* `disposeGraceMs` (parked on a promise no hook owns) is abandoned so
* `result` settles regardless (see {@link abandoned}). Idempotent; the
* first reason wins.
*/ */
cancel(reason?: string): void { cancel(reason?: string): void {
if (this.cancelReason !== undefined) return if (this.cancelReason !== undefined) return
@@ -234,14 +220,18 @@ export class WorkflowExecution {
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
this.controller.abort(this.cancelReason) this.controller.abort(this.cancelReason)
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
this.abandonTimer = setTimeout(() => { this.declareAbandoned() }, this.limits.disposeGraceMs)
// unref'd: an armed grace timer must never hold the process open.
this.abandonTimer.unref()
} }
/** /**
* Run the script to settlement. Resolves — never rejects — with the run's * Run the script to settlement. Resolves — never rejects — with the run's
* {@link WorkflowResult}: the materialized return value on `completed`, the * {@link WorkflowResult}: the materialized return value on `completed`, the
* failure message on `error`, and `cancelled` when the script died of * failure message on `error`, and `cancelled` when the script died of
* cancellation. After settlement, any stray children a script fired without * cancellation (or outlived its post-cancel grace and was abandoned — see
* awaiting are aborted (their `agent()` wrappers dispose them). * {@link abandoned}). After settlement, any stray children a script fired
* without awaiting are aborted (their `agent()` wrappers dispose them).
*/ */
async drive(): Promise<WorkflowResult> { async drive(): Promise<WorkflowResult> {
try { try {
@@ -249,7 +239,9 @@ export class WorkflowExecution {
// the script must not execute at all, let alone report `completed`. // the script must not execute at all, let alone report `completed`.
if (this.isCancelled()) throw this.cancelledError() if (this.isCancelled()) throw this.cancelledError()
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown> const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
const raw: unknown = await this.contain(Promise.resolve(scriptPromise)) // The race is the result-settles-after-cancel guarantee: a parked
// script loses to the abandon channel once the grace expires.
const raw: unknown = await Promise.race([this.contain(Promise.resolve(scriptPromise)), this.abandoned])
// Cancelled while the body ran: a script that settled without touching // Cancelled while the body ran: a script that settled without touching
// another hook (or without any) must still report `cancelled` — the // another hook (or without any) must still report `cancelled` — the
// holder asked for cancellation and `completed` would be a lie. // holder asked for cancellation and `completed` would be a lie.
@@ -258,25 +250,23 @@ export class WorkflowExecution {
return { value, stopReason: 'completed', agentsStarted: this.started } return { value, stopReason: 'completed', agentsStarted: this.started }
} catch (error: unknown) { } catch (error: unknown) {
// Any failure after cancel() reports `cancelled` with the canonical // Any failure after cancel() reports `cancelled` with the canonical
// reason — the reject path mirrors the resolve path's post-settle // reason — the reject path mirrors the resolve path's post-settle check.
// check, and a hook CANCELLED failure crosses the realm boundary as a
// clone that deliberately fails the host `instanceof`.
if (this.isCancelled()) { if (this.isCancelled()) {
return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started } return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
} }
// Ordinary script failures arrive pre-rendered by the realm-side catch // renderThrown is total (host- and realm-thrown values alike), so this
// (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError) // arm cannot throw — drive() resolving is the `result` never-rejects
// and adversarial values that bypassed the wrapper (e.g. a hostile // seam contract.
// thenable rejection) render via the total, host-code-only return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
// 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 { } finally {
// Reap strays: a script that fired agent() calls without awaiting them // Reap strays: a script that fired agent() calls without awaiting them
// leaves live children behind after settlement — abort them all. (The // leaves live children behind after settlement — abort them all. (The
// per-call wrappers dispose each child; the contain() consumer keeps // per-call wrappers dispose each child; the contain() consumer keeps
// their rejections from going unhandled.) // their rejections from going unhandled.)
if (this.cancelReason === undefined) this.cancel('workflow settled') if (this.cancelReason === undefined) this.cancel('workflow settled')
// drive() settling means nothing is left to abandon — including the
// timer the self-cancel above just armed.
if (this.abandonTimer !== undefined) clearTimeout(this.abandonTimer)
} }
} }
@@ -291,37 +281,6 @@ 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
@@ -354,14 +313,6 @@ export class WorkflowExecution {
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED') return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
} }
/** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */
private toRealm(value: unknown): unknown {
if (value === undefined) return undefined
if (value === null) return null
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
return this.realmJsonParse(JSON.stringify(value))
}
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */ /** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
private materializeResult(raw: unknown): unknown { private materializeResult(raw: unknown): unknown {
try { try {
@@ -453,7 +404,7 @@ export class WorkflowExecution {
return null return null
} }
this.observer.agentEnd({ ...info, outcome: 'completed' }) this.observer.agentEnd({ ...info, outcome: 'completed' })
return this.toRealm(result.structured) return result.structured
} }
this.observer.agentEnd({ ...info, outcome: 'completed' }) this.observer.agentEnd({ ...info, outcome: 'completed' })
return outputText(result.output) return outputText(result.output)
@@ -532,20 +483,17 @@ export class WorkflowExecution {
} }
return thunk as () => unknown return thunk as () => unknown
}) })
const settled = await Promise.all(thunks.map(async (thunk) => { return Promise.all(thunks.map(async (thunk) => {
try { try {
return await thunk() return await thunk()
} catch (error: unknown) { } catch (error: unknown) {
// Hooks translate host errors at the realm boundary, so a fatal error // Hook failures are host WorkflowErrors; a fatal one is recognized by
// reaches a thunk catch only as a realm clone (a script forging the // host `instanceof` — a script-built object can never pass it, so
// shape merely kills its own run). // fatality cannot be forged (nor accidentally dissolved).
if (isFatalWorkflowErrorClone(error)) throw error if (isFatalWorkflowError(error)) throw error
return null return null
} }
})) }))
// The container must be a REALM array (module doc); the elements are
// realm values already.
return this.realmArrayFrom(settled)
} }
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */ /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
@@ -563,7 +511,7 @@ export class WorkflowExecution {
} }
return stage as (previous: unknown, item: unknown, index: number) => unknown return stage as (previous: unknown, item: unknown, index: number) => unknown
}) })
const settled = await Promise.all(rawItems.map(async (item: unknown, index) => { return Promise.all(rawItems.map(async (item: unknown, index) => {
let value: unknown = item let value: unknown = item
try { try {
for (const stage of stages) { for (const stage of stages) {
@@ -572,15 +520,12 @@ 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 (a realm clone — see parallel()) // remaining stages; a fatal host WorkflowError (see parallel()) kills
// kills the whole script. // the whole script.
if (isFatalWorkflowErrorClone(error)) throw error if (isFatalWorkflowError(error)) throw error
return null return null
} }
})) }))
// The container must be a REALM array (module doc); the elements are
// realm values already.
return this.realmArrayFrom(settled)
} }
private assertItemCap(length: number, hook: string): void { private assertItemCap(length: number, hook: string): void {

View File

@@ -103,29 +103,19 @@ return 2`
}) })
it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => { it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => {
const error = bad('export const meta = { name: "x", description: "d", phases: [{ get title() { return "t" } }] }') const error = bad('export const meta = { name: "x", description: "d", whenToUse: () => 1 }')
expect(error.code).toBe('META_INVALID') expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('JSON data') expect(error.message).toContain('JSON data')
}) })
it('rejects a meta literal containing a proxy as META_INVALID — its traps never run', () => { it('a meta expression that THROWS maps to META_INVALID carrying the rendered value', () => {
// bad() rethrows anything that is not a WorkflowError, so a trap firing const error = bad('export const meta = { name: (() => { throw "nope" })(), description: "d" }\nreturn 1')
// ('trap ran') would fail this test instead of mapping to META_INVALID.
const error = bad('export const meta = { name: "x", description: "d", phases: new Proxy([], { getPrototypeOf() { throw new Error("trap ran") } }) }')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('proxies cannot cross')
})
it('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => {
// bad() rethrows anything that is not a WorkflowError, so a hostile value
// escaping the realm-side renderer raw would fail this test.
const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1')
expect(error.code).toBe('META_INVALID') expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('pure literal') expect(error.message).toContain('pure literal')
expect(error.message).toContain('[unrenderable thrown value]') expect(error.message).toContain('nope')
}) })
it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => { it('a spinning meta expression dies by the eval timeout', () => {
try { try {
extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50) extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50)
throw new Error('expected the extraction to time out') throw new Error('expected the extraction to time out')

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import * as vm from 'node:vm' import * as vm from 'node:vm'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts' import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts'
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */ /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
function inRealm(expression: string): unknown { function inRealm(expression: string): unknown {
@@ -35,17 +35,21 @@ describe('materializeFromRealm', () => {
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a') expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
}) })
it('never invokes accessors: a counting getter is rejected, not read', () => { it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => {
const counter = inRealm(` const counter = inRealm(`
(() => { (() => {
globalThis.reads = 0 globalThis.reads = 0
return { get x() { globalThis.reads += 1; return 1 } } return { get x() { globalThis.reads += 1; return globalThis.reads } }
})() })()
`) `)
expect(rejection(counter)).toContain('accessor properties cannot cross') expect(materializeFromRealm(counter)).toEqual({ x: 1 })
// The getter body never ran — descriptor inspection only. })
expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it…
expect(rejection(counter)).toContain('accessor') // …but materialization still never did it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => {
const hostile = inRealm("{ get x() { throw new Error('read failed') } }")
const message = rejection(hostile)
expect(message).toContain('reading the value threw')
expect(message).toContain('read failed')
}) })
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => { it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
@@ -81,39 +85,18 @@ describe('materializeFromRealm', () => {
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 }) expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
}) })
it('rejects proxies (root, nested, revoked, host-realm) WITHOUT running any trap', () => {
const trapped = inRealm(`new Proxy({ a: 1 }, {
ownKeys() { throw new Error('trap ran') },
getOwnPropertyDescriptor() { throw new Error('trap ran') },
getPrototypeOf() { throw new Error('trap ran') },
})`)
// A trap firing would surface 'trap ran' (a non-MaterializeError) instead.
expect(rejection(trapped)).toContain('proxies cannot cross')
expect(rejection(inRealm('{ nested: new Proxy([], {}) }'))).toContain('value.nested')
const revoked = inRealm('(() => { const r = Proxy.revocable({}, {}); r.revoke(); return r.proxy })()')
expect(rejection(revoked)).toContain('proxies cannot cross')
expect(rejection(new Proxy({}, {}))).toContain('proxies cannot cross')
})
it('rejects an object whose PROTOTYPE is a proxy without dereferencing through it', () => {
const value = inRealm(`Object.create(new Proxy({}, {
getPrototypeOf() { throw new Error('trap ran') },
}))`)
expect(rejection(value)).toContain('exotic prototype')
})
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => { it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular') expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()') const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } }) expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
}) })
it('rejects sparse arrays, accessor elements, and non-index array properties', () => { it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => {
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse') expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()')))
.toContain('accessor')
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()'))) expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
.toContain('non-index') .toContain('non-index')
expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()')))
.toEqual([7])
}) })
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => { it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
@@ -134,45 +117,29 @@ describe('materializeFromRealm', () => {
}) })
}) })
describe('describeThrown (host-side thrown-value rendering)', () => { describe('renderThrown', () => {
it('renders a HOST Error via its identity-verified native stack getter', () => { it('prefers the stack, for host and realm errors alike', () => {
const error = new Error('host failure') const host = renderThrown(new Error('host failure'))
const rendered = describeThrown(error) expect(host).toContain('host failure')
expect(rendered).toContain('host failure') expect(host).toContain('at ') // a real stack, not just the message
expect(rendered).toContain('at ') // a real stack, not just the message
})
it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => {
const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()') const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
expect(describeThrown(realmError)).toBe('realm failure') expect(renderThrown(realmError)).toContain('realm failure')
}) })
it('reads a data-property stack directly and falls through a setter-only accessor', () => { it('falls back from stack to message to String()', () => {
expect(describeThrown({ stack: 'data stack' })).toBe('data stack') expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack')
const setterOnly = { message: 'via message' } const stackless = new Error('stackless failure')
Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } }) delete stackless.stack
expect(describeThrown(setterOnly)).toBe('via message') expect(renderThrown(stackless)).toBe('stackless failure')
expect(renderThrown({ code: 42 })).toBe('[object Object]')
expect(renderThrown('plain')).toBe('plain')
expect(renderThrown(42)).toBe('42')
expect(renderThrown(undefined)).toBe('undefined')
expect(renderThrown(null)).toBe('null')
}) })
it('labels proxies and functions without touching them; primitives stringify', () => { it('is total: a value whose accessors/toString throw renders as a fixed label', () => {
expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]') expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
expect(describeThrown(() => 1)).toBe('[thrown function]') expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
expect(describeThrown('plain')).toBe('plain')
expect(describeThrown(42)).toBe('42')
expect(describeThrown(undefined)).toBe('undefined')
expect(describeThrown(null)).toBe('null')
expect(describeThrown({ code: 42 })).toBe('[object Object]')
})
})
describe('thrownRendering (the realm-catch wrapper reader)', () => {
it('extracts the pre-rendered string from a wrapper and nothing else', () => {
expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text')
expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined()
expect(thrownRendering({ other: 'x' })).toBeUndefined()
expect(thrownRendering(new Error('plain'))).toBeUndefined()
expect(thrownRendering('string')).toBeUndefined()
expect(thrownRendering(null)).toBeUndefined()
expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined()
}) })
}) })

View File

@@ -289,22 +289,13 @@ describe('dsh-workflow-vm', () => {
() => agent('fine'), () => agent('fine'),
() => 'plain value', () => 'plain value',
() => { throw 'string throw' }, () => { throw 'string throw' },
() => { throw new Proxy({ name: 'WorkflowError', fatal: true }, {}) }, () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
() => { throw { name: 'WorkflowError', fatal: 'forged-but-not-true' } },
]) ])
`)) `))
// The last three probe the fatal-clone recognition: a non-object, a // The last entry probes fatality: it is recognized by host instanceof,
// proxy (never inspected), and a shape miss are all ordinary nulls. // which a script-built object can never pass — a WorkflowError-SHAPED
expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null, null]) // throw is an ordinary null, and real fatality cannot be forged.
}) expect(result.value).toEqual([null, 'stub reply', 'plain value', 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 () => {
@@ -386,11 +377,12 @@ describe('dsh-workflow-vm', () => {
expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred') expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred')
}) })
it('rejects options that are not plain JSON data (an accessor smuggled into opts)', async () => { it('rejects options whose property reads throw (materialization is loud, not silent)', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
const result = await run(ctx, parent, script("return await agent('p', { get label() { return 'x' } })")) const result = await run(ctx, parent, script("return await agent('p', { get label() { throw new Error('read failed') } })"))
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
expect(result.error).toContain('options must be plain JSON data') expect(result.error).toContain('options must be plain JSON data')
expect(result.error).toContain('read failed')
}) })
it('validates phase() and log() arguments loudly', async () => { it('validates phase() and log() arguments loudly', async () => {
@@ -416,7 +408,7 @@ describe('dsh-workflow-vm', () => {
}) })
}) })
describe('determinism bans and realm isolation', () => { describe('determinism bans and the value boundary', () => {
it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => { it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available') expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available')
@@ -426,18 +418,16 @@ describe('dsh-workflow-vm', () => {
expect(ok.value).toBe(0) expect(ok.value).toBe(0)
}) })
it('args cross into the realm as data: mutating them (or their prototype chain) cannot reach host intrinsics', async () => { it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } }
const result = await run(ctx, parent, script(` const result = await run(ctx, parent, script(`
args.files.push('b.ts') args.files.push('b.ts')
Object.getPrototypeOf(args).polluted = 'realm-only'
return { count: args.files.length, deep: args.nested.deep[1] } return { count: args.files.length, deep: args.nested.deep[1] }
`), hostArgs) `), hostArgs)
expect(result.value).toEqual({ count: 2, deep: 2 }) expect(result.value).toEqual({ count: 2, deep: 2 })
// The host copy is untouched, and the HOST Object.prototype was never reachable. // The caller's object is untouched (the engine cloned args host-side).
expect(hostArgs.files).toEqual(['a.ts']) expect(hostArgs.files).toEqual(['a.ts'])
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
}) })
it('scalar/null args pass through directly; absent args leave the global undefined', async () => { it('scalar/null args pass through directly; absent args leave the global undefined', async () => {
@@ -447,51 +437,24 @@ 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 () => { it('hook failures reach the script as HOST WorkflowErrors: fields readable, in-realm instanceof Error is false', 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 { ctx, parent } = await setup()
const result = await run(ctx, parent, script(` const result = await run(ctx, parent, script(`
try { try {
await agent('p', { bogus: true }) await agent('p', { bogus: true })
return 'unreachable' return 'unreachable'
} catch (e) { } catch (e) {
Object.getPrototypeOf(Object.getPrototypeOf(e)).wfErrLeakProbe = 'realm-only' // The documented consequence of the trust premise: hook errors are
// host objects, so realm instanceof is false — read the fields.
return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message } return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message }
} }
`)) `))
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) expect(result.value).toMatchObject({ isRealmError: false, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true })
expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') 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 () => { it('a non-WorkflowError host failure (a rejecting provider result) reaches the script raw', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
const provider: SubagentProvider = { const provider: SubagentProvider = {
@@ -507,68 +470,34 @@ describe('dsh-workflow-vm', () => {
ctx.subagents.registerProvider(provider) ctx.subagents.registerProvider(provider)
await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' })
const result = await run(ctx, fakeParent(), script(` 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 } } try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, message: e.message } }
`)) `))
expect(result.value).toMatchObject({ isRealmError: true, name: 'Error' }) expect(result.value).toMatchObject({ name: 'Error' })
expect((result.value as { message: string }).message).toContain('backend exploded') expect((result.value as { message: string }).message).toContain('backend exploded')
}) })
it('phase()/log() synchronous throws cross as realm clones too', async () => { it('phase()/log() throw host WorkflowErrors synchronously on misuse', 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(`
try { phase(3) } catch (e) { try { phase(3) } catch (e) {
if (!(e instanceof Error) || e.name !== 'WorkflowError') throw e if (e.name !== 'WorkflowError') throw e
} }
try { log(3) } catch (e) { try { log(3) } catch (e) {
return { isRealmError: e instanceof Error, name: e.name, message: e.message } return { name: e.name, message: e.message }
} }
`)) `))
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError' }) expect(result.value).toMatchObject({ name: 'WorkflowError' })
expect((result.value as { message: string }).message).toContain('log() requires') 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('a returned value whose property reads throw fails loud as RESULT_UNSERIALIZABLE', 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(`
const fromParallel = await parallel([() => agent('a'), () => 'plain']) return { get a() { throw new Error('read failed') } }
const fromPipeline = await pipeline([1], (prev) => prev + 1)
Object.getPrototypeOf(fromParallel).polluted = 'realm-only'
return {
parallelIsRealmArray: fromParallel instanceof Array,
pipelineIsRealmArray: fromPipeline instanceof Array,
values: [fromParallel[1], fromPipeline[0]],
}
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({
parallelIsRealmArray: true,
pipelineIsRealmArray: true,
values: ['plain', 2],
})
// The script's prototype mutation stayed realm-side: the HOST
// Array.prototype was never reachable through a combinator result.
expect(([] as unknown as Record<string, unknown>).polluted).toBeUndefined()
})
it('a returned proxy is rejected as RESULT_UNSERIALIZABLE without running its traps', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
return new Proxy({ a: 1 }, { ownKeys() { throw new Error('trap ran') } })
`)) `))
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
expect(result.error).toContain('not plain JSON data') expect(result.error).toContain('not plain JSON data')
expect(result.error).toContain('proxies cannot cross') expect(result.error).toContain('read failed')
expect(result.error).not.toContain('trap ran')
})
it('agent() options passed as a proxy are rejected loudly, traps never invoked', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
return await agent('p', new Proxy({}, { ownKeys() { throw new Error('trap ran') } }))
`))
expect(result.stopReason).toBe('error')
expect(result.error).toContain('options must be plain JSON data')
expect(result.error).not.toContain('trap ran')
}) })
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
@@ -696,60 +625,6 @@ describe('dsh-workflow-vm', () => {
expect(result.error).toBe('[object Object]') expect(result.error).toBe('[object Object]')
}) })
it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => {
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const { ctx, parent } = await setup()
// Each thrown value runs code (or throws) when rendered — the realm
// wrapper renders it INSIDE script execution, and the host catch only
// ever descriptor-reads the pre-rendered string.
const cases: [string, string][] = [
["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'],
["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'],
["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'],
["throw { stack: 'custom data stack' }", 'custom data stack'],
["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'],
["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'],
["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'],
['throw () => 1', '() => 1'],
['throw null', 'null'],
]
for (const [body, rendered] of cases) {
const result = await run(ctx, parent, script(body))
expect(result.stopReason).toBe('error')
expect(result.error).toBe(rendered)
}
// Let any stray rejection reach the process hook before asserting.
await new Promise(resolve => setTimeout(resolve, 20))
expect(unhandled).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } })
// The realm-side renderer reads e.stack INSIDE the timed sync slice, so
// the spin is killed exactly like a plain `while (true) {}` body.
const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }'))
expect(result.stopReason).toBe('error')
expect(result.error?.toLowerCase()).toContain('timed out')
})
it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => {
const { ctx, parent } = await setup()
// Returning a thenable makes the host unwrap it AFTER the script
// settled — its rejection value skips the realm catch entirely and hits
// drive()'s catch raw. The proxy must be labelled, its traps never run.
const result = await run(ctx, parent, script(`
return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } }
`))
expect(result.stopReason).toBe('error')
expect(result.error).toBe('[thrown proxy]')
})
it('falls back to the message for an Error whose stack was stripped', async () => { it('falls back to the message for an Error whose stack was stripped', 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(`
@@ -803,18 +678,43 @@ describe('dsh-workflow-vm', () => {
} }
}) })
it('dispose() abandons a stuck script after the grace instead of hanging (result stays pending)', async () => { it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({
// No hooks involved: an unsettleable await cancellation cannot reject
// — the abandon grace is the only thing that can settle this run.
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
parent,
})
handle.cancel('user aborted')
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('user aborted')
await handle.dispose()
})
it('a never-settling returned thenable is abandoned the same way', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({ script: script('return { then() {} }'), parent })
handle.cancel()
expect((await handle.result).stopReason).toBe('cancelled')
await handle.dispose()
})
it('dispose() abandons a stuck script after the grace instead of hanging (result settles cancelled)', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
// No hooks involved: an unsettleable await the engine cannot reject.
script: script("await new Promise(() => {})\nreturn 'unreachable'"), script: script("await new Promise(() => {})\nreturn 'unreachable'"),
parent, parent,
}) })
const before = Date.now() const before = Date.now()
await handle.dispose() await handle.dispose()
expect(Date.now() - before).toBeLessThan(1000) expect(Date.now() - before).toBeLessThan(1000)
const settled = await Promise.race([handle.result.then(() => 'settled'), Promise.resolve('pending')]) // The abandon that freed dispose() also settled result — a consumer
expect(settled).toBe('pending') // still awaiting it (the tool does, before its disposing finally) is
// released rather than wedged forever.
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
}) })
it('dispose() is idempotent and settles cleanly after a completed run', async () => { it('dispose() is idempotent and settles cleanly after a completed run', async () => {

View File

@@ -4,7 +4,7 @@ The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a wor
## Service: `WorkflowService` (abstract) ## Service: `WorkflowService` (abstract)
`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. `start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` settles within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller.
The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits. The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits.

View File

@@ -127,8 +127,8 @@ export type WorkflowEventName =
* subset (see dsh-tools). * subset (see dsh-tools).
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
* - `AGENT_START` — the subagent seam refused to start a child. * - `AGENT_START` — the subagent seam refused to start a child.
* - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary
* plain JSON data. * is not plain JSON data.
* - `CANCELLED` — the run was cancelled; pending and future hooks reject * - `CANCELLED` — the run was cancelled; pending and future hooks reject
* with this (the script-kill mechanism). * with this (the script-kill mechanism).
*/ */
@@ -179,7 +179,10 @@ export function isFatalWorkflowError(error: unknown): boolean {
* - {@link start} throws synchronously for a request that cannot begin (an * - {@link start} throws synchronously for a request that cannot begin (an
* unparseable script, an invalid meta block). Once it returns a * unparseable script, an invalid meta block). Once it returns a
* {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with * {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with
* `stopReason: 'error'` (or `'cancelled'`). * `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled,
* `result` SETTLES within the implementation's bounded grace even if the
* script itself never settles (a consumer awaiting `result` must never be
* wedged past a cancellation).
* - The `workflow/*` events fire through {@link emitWorkflowEvent} (data * - The `workflow/*` events fire through {@link emitWorkflowEvent} (data
* snapshots, per-listener containment); `workflow/end` fires exactly once * snapshots, per-listener containment); `workflow/end` fires exactly once
* per started run, after `result` is settled or as it settles. * per started run, after `result` is settled or as it settles.

View File

@@ -85,7 +85,7 @@ export interface WorkflowResult {
stopReason: WorkflowStopReason stopReason: WorkflowStopReason
/** The failure message (present iff `stopReason` is not `completed`). */ /** The failure message (present iff `stopReason` is not `completed`). */
error?: string error?: string
/** How many `agent()` calls the run started (across its whole lifetime). */ /** How many `agent()` calls the run accepted (whole lifetime, including calls still queued for a slot when the run was cancelled). */
agentsStarted: number agentsStarted: number
} }
@@ -93,17 +93,19 @@ export interface WorkflowResult {
* The handle the consumer holds while a script executes. The consumer awaits * The handle the consumer holds while a script executes. The consumer awaits
* `result`, may `cancel` mid-flight, and MUST `dispose` on every path. * `result`, may `cancel` mid-flight, and MUST `dispose` on every path.
* `result` does NOT reject — a script failure resolves with `stopReason: * `result` does NOT reject — a script failure resolves with `stopReason:
* 'error'` — so the consumer maps a non-`completed` reason to an `isError` * 'error'` — and once the run is cancelled it SETTLES within the engine's
* result. `dispose()` cancels, then waits a bounded grace for the script to * bounded grace even if the script itself never settles (the engine abandons
* settle before abandoning it (the engine documents the abandonment * the script and reports `cancelled`), so a consumer awaiting `result` is
* semantics); it never hangs on a stuck script. * never wedged past a cancellation. `dispose()` = cancel + that bounded
* settle + child quiescence; it never hangs on a stuck script and is safe to
* call on every path (idempotent).
*/ */
export interface WorkflowRun { export interface WorkflowRun {
readonly id: WorkflowRunId readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */ /** The validated meta block (available before the body runs). */
readonly meta: WorkflowMeta readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult> readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */ /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is abandoned at the grace). */
cancel(reason?: string): void cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
dispose(): Promise<void> dispose(): Promise<void>
@@ -149,6 +151,6 @@ export interface WorkflowResultInfo {
stopReason: WorkflowStopReason stopReason: WorkflowStopReason
/** The failure message (present iff `stopReason` is not `completed`). */ /** The failure message (present iff `stopReason` is not `completed`). */
error?: string error?: string
/** How many `agent()` calls the run started. */ /** How many `agent()` calls the run accepted (see {@link WorkflowResult.agentsStarted}). */
agentsStarted: number agentsStarted: number
} }

6
pnpm-lock.yaml generated
View File

@@ -1060,6 +1060,9 @@ importers:
'@deepseek-ai/dsh-session': '@deepseek-ai/dsh-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/session version: link:../../core/session
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
'@deepseek-ai/dsh-system-prompt': '@deepseek-ai/dsh-system-prompt':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/system-prompt version: link:../../core/system-prompt
@@ -1069,6 +1072,9 @@ importers:
'@deepseek-ai/dsh-workflow': '@deepseek-ai/dsh-workflow':
specifier: workspace:^ specifier: workspace:^
version: link:../workflow version: link:../workflow
'@deepseek-ai/dsh-workflow-vm':
specifier: workspace:^
version: link:../workflow-vm
cordis: cordis:
specifier: ^4.0.0-rc.6 specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)